├── packages ├── web │ ├── README.md │ ├── public │ │ ├── favicon.ico │ │ ├── favicon.png │ │ ├── logo-le.png │ │ ├── VeraMono.woff │ │ ├── hero-bg.webp │ │ ├── logo-poe1.webp │ │ ├── logo-poe2.webp │ │ ├── Fontin-Italic.woff │ │ ├── Fontin-Regular.woff │ │ ├── Fontin-SmallCaps.woff │ │ ├── LiberationSans-Bold.woff │ │ ├── LiberationSans-Regular.woff │ │ └── manifest.json │ ├── wrangler.toml │ ├── react-router.config.ts │ ├── src │ │ ├── vite-env.d.ts │ │ ├── routes.ts │ │ ├── app.css │ │ ├── routes │ │ │ ├── _game.le.versions.$version.tsx │ │ │ ├── _game.poe1.versions.$version.tsx │ │ │ ├── _game.poe2.versions.$version.tsx │ │ │ ├── _game.le._index.tsx │ │ │ ├── _game.poe1._index.tsx │ │ │ ├── _game.poe2._index.tsx │ │ │ └── _game.tsx │ │ ├── entry.client.tsx │ │ ├── components │ │ │ ├── HomeButton.tsx │ │ │ ├── HelpButton.tsx │ │ │ ├── SettingsButton.tsx │ │ │ ├── AuthButton.tsx │ │ │ ├── PoBController.tsx │ │ │ ├── ErrorDialog.tsx │ │ │ ├── HelpDialog.tsx │ │ │ ├── PoBWindow.tsx │ │ │ └── SettingsDialog.tsx │ │ ├── lib │ │ │ └── logger.ts │ │ ├── entry.server.tsx │ │ └── root.tsx │ ├── tsconfig.node.json │ ├── functions │ │ ├── tsconfig.json │ │ └── api │ │ │ ├── kv │ │ │ ├── _middleware.ts │ │ │ └── [[name]].ts │ │ │ └── fetch.ts │ ├── tsconfig.json │ ├── package.json │ └── vite.config.ts ├── driver │ ├── README.md │ ├── public │ │ ├── VeraMono.woff │ │ ├── Fontin-Italic.woff │ │ ├── Fontin-Regular.woff │ │ ├── Fontin-SmallCaps.woff │ │ ├── LiberationSans-Bold.woff │ │ └── LiberationSans-Regular.woff │ ├── src │ │ ├── c │ │ │ ├── util.h │ │ │ ├── lcurl.h │ │ │ ├── sub.h │ │ │ ├── image.h │ │ │ ├── draw.h │ │ │ ├── fs.h │ │ │ ├── util.c │ │ │ ├── wasmfs │ │ │ │ ├── support.h │ │ │ │ ├── backend.h │ │ │ │ ├── wasmfs.h │ │ │ │ ├── nodefs.h │ │ │ │ └── file_table.h │ │ │ ├── image.c │ │ │ └── fs.c │ │ └── js │ │ │ ├── error.ts │ │ │ ├── renderer │ │ │ ├── index.ts │ │ │ └── backend.ts │ │ │ ├── vite-env.d.ts │ │ │ ├── overlay │ │ │ ├── overlay.css │ │ │ ├── ToolbarButton.tsx │ │ │ ├── types.ts │ │ │ ├── index.ts │ │ │ ├── useFullscreen.ts │ │ │ ├── KeyButton.tsx │ │ │ ├── ModifierButton.tsx │ │ │ ├── Toolbar.tsx │ │ │ ├── ZoomControl.tsx │ │ │ ├── OverlayContainer.tsx │ │ │ └── PerformanceOverlay.tsx │ │ │ ├── run.ts │ │ │ ├── event.ts │ │ │ ├── sub.ts │ │ │ ├── image.ts │ │ │ ├── logger.ts │ │ │ ├── keyboard.ts │ │ │ └── fs.ts │ ├── gen_boot_c.cmake │ ├── tsconfig.json │ ├── package.json │ ├── index.html │ ├── vite.config.ts │ ├── CMakeLists.txt │ └── boot.lua ├── dds │ ├── README.md │ ├── src │ │ ├── index.ts │ │ ├── dds.test.ts │ │ └── storage.ts │ ├── package.json │ └── tsconfig.json ├── game │ ├── package.json │ ├── tsconfig.json │ └── src │ │ └── index.ts └── packer │ ├── package.json │ ├── tsconfig.json │ └── src │ └── pack.ts ├── .github ├── .release-please-manifest.json ├── release-please-config.json └── workflows │ ├── build.yml │ └── sync-upstream.yml ├── .gitignore ├── .gitmodules ├── .editorconfig ├── .devcontainer └── devcontainer.json ├── biome.json ├── .mise.pob-cool.toml ├── LICENSE ├── hk.pkl ├── package.json ├── .env.pob-cool.yaml ├── .mise.toml ├── CLAUDE.md ├── version.json └── README.md /packages/web/README.md: -------------------------------------------------------------------------------- 1 | # pob-web 2 | 3 | -------------------------------------------------------------------------------- /.github/.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {".":"0.27.5"} 2 | -------------------------------------------------------------------------------- /packages/driver/README.md: -------------------------------------------------------------------------------- 1 | # pob-driver 2 | 3 | This emulates PoB window with vanilla JS. 4 | -------------------------------------------------------------------------------- /packages/dds/README.md: -------------------------------------------------------------------------------- 1 | # dds 2 | 3 | This is for parsing Microsoft DirectDraw Surface (.DDS) files. 4 | -------------------------------------------------------------------------------- /packages/dds/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./dds"; 2 | export * from "./format"; 3 | export * from "./storage"; 4 | -------------------------------------------------------------------------------- /packages/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/favicon.png -------------------------------------------------------------------------------- /packages/web/public/logo-le.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/logo-le.png -------------------------------------------------------------------------------- /packages/web/public/VeraMono.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/VeraMono.woff -------------------------------------------------------------------------------- /packages/web/public/hero-bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/hero-bg.webp -------------------------------------------------------------------------------- /packages/driver/public/VeraMono.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/driver/public/VeraMono.woff -------------------------------------------------------------------------------- /packages/web/public/logo-poe1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/logo-poe1.webp -------------------------------------------------------------------------------- /packages/web/public/logo-poe2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/logo-poe2.webp -------------------------------------------------------------------------------- /packages/web/public/Fontin-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/Fontin-Italic.woff -------------------------------------------------------------------------------- /packages/driver/public/Fontin-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/driver/public/Fontin-Italic.woff -------------------------------------------------------------------------------- /packages/web/public/Fontin-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/Fontin-Regular.woff -------------------------------------------------------------------------------- /packages/web/public/Fontin-SmallCaps.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/Fontin-SmallCaps.woff -------------------------------------------------------------------------------- /packages/driver/public/Fontin-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/driver/public/Fontin-Regular.woff -------------------------------------------------------------------------------- /packages/driver/public/Fontin-SmallCaps.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/driver/public/Fontin-SmallCaps.woff -------------------------------------------------------------------------------- /packages/web/public/LiberationSans-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/LiberationSans-Bold.woff -------------------------------------------------------------------------------- /packages/driver/public/LiberationSans-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/driver/public/LiberationSans-Bold.woff -------------------------------------------------------------------------------- /packages/web/public/LiberationSans-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/web/public/LiberationSans-Regular.woff -------------------------------------------------------------------------------- /packages/driver/public/LiberationSans-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atty303/pob-web/HEAD/packages/driver/public/LiberationSans-Regular.woff -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.iml 3 | .idea/ 4 | /work 5 | dist/ 6 | dist-pages/ 7 | node_modules/ 8 | build/ 9 | r2/ 10 | .wrangler 11 | .react-router/ 12 | -------------------------------------------------------------------------------- /packages/driver/src/c/util.h: -------------------------------------------------------------------------------- 1 | #ifndef DRIVER_UTIL_H 2 | #define DRIVER_UTIL_H 3 | 4 | void log_error(const char *fmt, ...); 5 | 6 | #endif //DRIVER_UTIL_H 7 | -------------------------------------------------------------------------------- /packages/web/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "pob-web" 2 | compatibility_date = "2024-05-12" 3 | 4 | [[kv_namespaces]] 5 | binding = "KV" 6 | id = "543654636f6943558c4afdd3a8a53016" 7 | -------------------------------------------------------------------------------- /packages/driver/src/c/lcurl.h: -------------------------------------------------------------------------------- 1 | #ifndef DRIVER_LCURL_H 2 | #define DRIVER_LCURL_H 3 | 4 | #include "lua.h" 5 | 6 | void lcurl_register(lua_State *L); 7 | 8 | #endif //DRIVER_LCURL_H 9 | -------------------------------------------------------------------------------- /packages/web/react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | appDirectory: "src", 5 | ssr: false, 6 | } satisfies Config; 7 | -------------------------------------------------------------------------------- /packages/driver/src/js/error.ts: -------------------------------------------------------------------------------- 1 | export class UserError extends Error { 2 | constructor(message: string | undefined) { 3 | super(message); 4 | this.name = "UserError"; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare const APP_VERSION: string; 3 | declare const __VERSION_URL__: string; 4 | declare const __ASSET_PREFIX__: string; 5 | -------------------------------------------------------------------------------- /packages/dds/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dds", 3 | "private": true, 4 | "version": "", 5 | "type": "module", 6 | "files": ["dist"], 7 | "scripts": { 8 | "build": "tsc" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/web/src/routes.ts: -------------------------------------------------------------------------------- 1 | import type { RouteConfig } from "@react-router/dev/routes"; 2 | import { flatRoutes } from "@react-router/fs-routes"; 3 | 4 | export default flatRoutes() satisfies RouteConfig; 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/lua"] 2 | path = vendor/lua 3 | url = https://github.com/atty303/lua.git 4 | [submodule "vendor/luautf8"] 5 | path = vendor/luautf8 6 | url = https://github.com/starwing/luautf8.git 7 | -------------------------------------------------------------------------------- /packages/game/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pob-game", 3 | "private": true, 4 | "version": "", 5 | "type": "module", 6 | "dependencies": {}, 7 | "scripts": { 8 | "build": "true" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | insert_final_newline = true 9 | max_line_length = 120 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /packages/driver/gen_boot_c.cmake: -------------------------------------------------------------------------------- 1 | file(READ boot.lua file_content HEX) 2 | string(REGEX REPLACE "(..)" "\\\\x\\1" c_string ${file_content}) 3 | file(WRITE ${CMAKE_BINARY_DIR}/boot.c "const char* boot_lua = \"${c_string}\";\n") 4 | -------------------------------------------------------------------------------- /packages/web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "nodenext" 6 | }, 7 | "include": ["react-router.config.ts", "vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/driver/src/js/renderer/index.ts: -------------------------------------------------------------------------------- 1 | export { Renderer, type LayerStats, type RenderStats } from "./renderer"; 2 | export * from "./text"; 3 | export { WebGL1Backend } from "./webgl_backend"; 4 | export { WebGPUBackend } from "./webgpu_backend"; 5 | -------------------------------------------------------------------------------- /packages/web/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "bundler", 6 | "lib": ["esnext"], 7 | "types": ["@cloudflare/workers-types"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/driver/src/js/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare const __ASSET_PREFIX__: string; 3 | 4 | declare const __RUN_GAME__: string; 5 | declare const __RUN_VERSION__: string; 6 | declare const __RUN_BUILD__: "release" | "debug"; 7 | -------------------------------------------------------------------------------- /packages/driver/src/c/sub.h: -------------------------------------------------------------------------------- 1 | #ifndef DRIVER_SUB_H 2 | #define DRIVER_SUB_H 3 | 4 | #include 5 | #include "lua.h" 6 | 7 | void sub_init(lua_State *L); 8 | int sub_lua_deserialize(lua_State *L, const uint8_t *serializedData); 9 | 10 | #endif //DRIVER_SUB_H 11 | -------------------------------------------------------------------------------- /packages/web/src/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "@tailwindcss/typography"; 3 | @plugin "daisyui" { 4 | theme: dark --default; 5 | logs: false; 6 | } 7 | 8 | /* https://github.com/saadeghi/daisyui/issues/3040 */ 9 | html { 10 | scrollbar-gutter: auto !important; 11 | } 12 | -------------------------------------------------------------------------------- /packages/driver/src/c/image.h: -------------------------------------------------------------------------------- 1 | #ifndef DRIVER_IMAGE_H 2 | #define DRIVER_IMAGE_H 3 | 4 | #include "lua.h" 5 | 6 | typedef struct { 7 | int handle; 8 | int width; 9 | int height; 10 | } ImageHandle; 11 | 12 | extern void image_init(lua_State *L); 13 | 14 | #endif //DRIVER_IMAGE_H 15 | -------------------------------------------------------------------------------- /packages/driver/src/c/draw.h: -------------------------------------------------------------------------------- 1 | #ifndef DRIVER_DRAW_H 2 | #define DRIVER_DRAW_H 3 | 4 | #include "lua.h" 5 | 6 | extern void draw_init(lua_State *L); 7 | extern void draw_begin(); 8 | extern void draw_get_buffer(void **data, size_t *size); 9 | extern void draw_end(); 10 | 11 | #endif //DRIVER_DRAW_H 12 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/overlay.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" prefix(pw); 2 | @plugin "@tailwindcss/typography"; 3 | 4 | @plugin "daisyui" { 5 | theme: dark --default; 6 | logs: false; 7 | } 8 | 9 | /* https://github.com/saadeghi/daisyui/issues/3040 */ 10 | html { 11 | scrollbar-gutter: auto !important; 12 | } 13 | -------------------------------------------------------------------------------- /packages/web/src/routes/_game.le.versions.$version.tsx: -------------------------------------------------------------------------------- 1 | import PoBController from "../components/PoBController"; 2 | import type { Route } from "../routes/+types/_game.le.versions.$version"; 3 | 4 | export default function (p: Route.ComponentProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/src/routes/_game.poe1.versions.$version.tsx: -------------------------------------------------------------------------------- 1 | import PoBController from "../components/PoBController"; 2 | import type { Route } from "../routes/+types/_game.poe1.versions.$version"; 3 | 4 | export default function (p: Route.ComponentProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/src/routes/_game.poe2.versions.$version.tsx: -------------------------------------------------------------------------------- 1 | import PoBController from "../components/PoBController"; 2 | import type { Route } from "../routes/+types/_game.poe2.versions.$version"; 3 | 4 | export default function (p: Route.ComponentProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/src/routes/_game.le._index.tsx: -------------------------------------------------------------------------------- 1 | import PoBController from "../components/PoBController"; 2 | import type { Route } from "../routes/+types/_game.le._index"; 3 | 4 | export default function (p: Route.ComponentProps) { 5 | const { games } = p.matches[1].data; 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/src/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode, startTransition } from "react"; 2 | import { hydrateRoot } from "react-dom/client"; 3 | import { HydratedRouter } from "react-router/dom"; 4 | 5 | startTransition(() => { 6 | hydrateRoot( 7 | document, 8 | 9 | 10 | , 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/web/src/routes/_game.poe1._index.tsx: -------------------------------------------------------------------------------- 1 | import PoBController from "../components/PoBController"; 2 | import type { Route } from "../routes/+types/_game.poe1._index"; 3 | 4 | export default function (p: Route.ComponentProps) { 5 | const { games } = p.matches[1].data; 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/src/routes/_game.poe2._index.tsx: -------------------------------------------------------------------------------- 1 | import PoBController from "../components/PoBController"; 2 | import type { Route } from "../routes/+types/_game.poe2._index"; 3 | 4 | export default function (p: Route.ComponentProps) { 5 | const { games } = p.matches[1].data; 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /packages/packer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pob-packer", 3 | "private": true, 4 | "version": "", 5 | "type": "module", 6 | "dependencies": { 7 | "@bokuweb/zstd-wasm": "^0.0.22", 8 | "pob-game": "*", 9 | "dds": "*" 10 | }, 11 | "scripts": { 12 | "pack": "tsx src/pack.ts", 13 | "sync": "tsx src/sync.ts", 14 | "build": "true" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/driver/src/c/fs.h: -------------------------------------------------------------------------------- 1 | #ifndef DRIVER_FS_H 2 | #define DRIVER_FS_H 3 | 4 | #include 5 | #include "lua.h" 6 | 7 | typedef struct { 8 | char path[PATH_MAX]; 9 | char pattern[PATH_MAX]; 10 | DIR *dir; 11 | struct dirent *entry; 12 | int dir_only; 13 | } FsReaddirHandle; 14 | 15 | extern void fs_init(lua_State *L); 16 | 17 | #endif //DRIVER_FS_H 18 | -------------------------------------------------------------------------------- /packages/dds/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 5 | "module": "ES2022", 6 | "moduleResolution": "bundler", 7 | "noEmit": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "target": "ES2022" 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/driver/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 4 | "module": "ES2022", 5 | "moduleResolution": "bundler", 6 | "noEmit": true, 7 | "resolveJsonModule": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "target": "ES2022", 11 | "types": ["@webgpu/types"], 12 | "jsx": "react-jsx" 13 | }, 14 | "include": ["src/js"] 15 | } 16 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 3 | "features": { 4 | "ghcr.io/atty303/devcontainer-features/mise:1": { 5 | "activate": "shims", 6 | "install": false 7 | } 8 | }, 9 | "initializeCommand": "git submodule update --init --recursive", 10 | "customizations": { 11 | "vscode": { 12 | "extensions": ["hverlin.mise-vscode"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/src/components/HomeButton.tsx: -------------------------------------------------------------------------------- 1 | import { HomeIcon } from "@heroicons/react/24/solid"; 2 | import type React from "react"; 3 | 4 | interface HomeButtonProps { 5 | position: "top" | "bottom" | "left" | "right"; 6 | isLandscape: boolean; 7 | } 8 | 9 | export const HomeButton: React.FC = () => { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "release-type": "node", 4 | "include-component-in-tag": false, 5 | "extra-files": [ 6 | { 7 | "type": "json", 8 | "path": "packages/driver/package.json", 9 | "jsonpath": "$.version" 10 | }, 11 | { 12 | "type": "json", 13 | "path": "packages/web/package.json", 14 | "jsonpath": "$.version" 15 | } 16 | ], 17 | "packages": { 18 | ".": {} 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/game/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2021", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | 15 | "strict": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/packer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2021", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | 15 | "strict": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/src/routes/_game.tsx: -------------------------------------------------------------------------------- 1 | import type { Game } from "pob-game/src"; 2 | import { Outlet } from "react-router"; 3 | import type { Route } from "../routes/+types/_game"; 4 | 5 | export type Games = { 6 | [key in Game]: { head: string; versions: { value: string; date: string }[] }; 7 | }; 8 | 9 | export async function clientLoader(args: Route.ClientLoaderArgs) { 10 | const rep = await fetch(__VERSION_URL__); 11 | const games = (await rep.json()) as Games; 12 | return { games }; 13 | } 14 | 15 | export default function () { 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/src/components/HelpButton.tsx: -------------------------------------------------------------------------------- 1 | import { QuestionMarkCircleIcon } from "@heroicons/react/24/solid"; 2 | import type React from "react"; 3 | 4 | interface HelpButtonProps { 5 | position: "top" | "bottom" | "left" | "right"; 6 | isLandscape: boolean; 7 | onOpenHelp: () => void; 8 | } 9 | 10 | export const HelpButton: React.FC = ({ onOpenHelp }) => { 11 | return ( 12 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /packages/driver/src/js/renderer/backend.ts: -------------------------------------------------------------------------------- 1 | import type { TextureBitmap } from "./renderer"; 2 | 3 | export interface RenderBackend { 4 | readonly canvas: OffscreenCanvas; 5 | 6 | resize(width: number, height: number, pixelRatio: number): void; 7 | setViewport(x: number, y: number, width: number, height: number): void; 8 | beginFrame(): void; 9 | begin(): void; 10 | end(): void; 11 | drawQuad( 12 | coords: number[], 13 | texCoords: number[], 14 | textureBitmap: TextureBitmap, 15 | tintColor: number[], 16 | stackLayer: number, 17 | maskLayer: number, 18 | ): void; 19 | } 20 | -------------------------------------------------------------------------------- /packages/web/src/components/SettingsButton.tsx: -------------------------------------------------------------------------------- 1 | import { Cog6ToothIcon } from "@heroicons/react/24/solid"; 2 | import type React from "react"; 3 | 4 | interface SettingsButtonProps { 5 | position: "top" | "bottom" | "left" | "right"; 6 | isLandscape: boolean; 7 | onOpenSettings: () => void; 8 | } 9 | 10 | export const SettingsButton: React.FC = ({ onOpenSettings }) => { 11 | return ( 12 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /packages/dds/src/dds.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import * as zstd from "@bokuweb/zstd-wasm"; 3 | import { parseDDSDX10 } from "dds/src"; 4 | 5 | await zstd.init(); 6 | 7 | const file = fs.readFileSync( 8 | // "../packer/build/2/v0.3.0/repo/src/TreeData/0_1/ascendancy-background_1500_1500_BC7.dds.zst", 9 | // "../packer/build/2/v0.6.0/repo/src/TreeData/0_1/skills_176_176_BC1.dds.zst", 10 | "../packer/build/2/v0.6.0/repo/src/TreeData/0_2/skills_176_176_BC1.dds.zst", 11 | ); 12 | const data = zstd.decompress(new Uint8Array(file)); 13 | fs.writeFileSync("dds.dds", data); 14 | const dds = parseDDSDX10(data); 15 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/ToolbarButton.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | interface ToolbarButtonProps { 4 | icon: React.ReactNode; 5 | tooltip: string; 6 | onClick: () => void; 7 | isActive?: boolean; 8 | } 9 | 10 | export const ToolbarButton: React.FC = ({ icon, tooltip, onClick, isActive = false }) => { 11 | const baseClasses = "pw:btn pw:btn-square"; 12 | const variantClasses = isActive ? "pw:btn-primary" : "pw:btn-ghost"; 13 | 14 | return ( 15 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/types.ts: -------------------------------------------------------------------------------- 1 | export interface ModifierKeys { 2 | ctrl: boolean; 3 | shift: boolean; 4 | alt: boolean; 5 | } 6 | 7 | export interface ToolbarCallbacks { 8 | onZoomReset: () => void; 9 | onZoomChange: (zoom: number) => void; 10 | onCanvasSizeChange: (width: number, height: number) => void; 11 | onFixedSizeToggle: (isFixed: boolean) => void; 12 | onLayoutChange: () => void; 13 | onFullscreenToggle: () => void; 14 | onPanModeToggle: (enabled: boolean) => void; 15 | onKeyboardToggle: () => void; 16 | onPerformanceToggle: () => void; 17 | } 18 | 19 | export type ToolbarPosition = "top" | "bottom" | "left" | "right"; 20 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json", 3 | "formatter": { 4 | "indentStyle": "space", 5 | "indentWidth": 2, 6 | "lineEnding": "lf", 7 | "lineWidth": 120 8 | }, 9 | "javascript": { 10 | "formatter": { 11 | "arrowParentheses": "asNeeded", 12 | "semicolons": "always" 13 | } 14 | }, 15 | "organizeImports": { 16 | "enabled": true 17 | }, 18 | "linter": { 19 | "enabled": true, 20 | "rules": { 21 | "recommended": true, 22 | "style": { "noUselessElse": "off", "noNonNullAssertion": "off" }, 23 | "a11y": { "noSvgWithoutTitle": "off" } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/index.ts: -------------------------------------------------------------------------------- 1 | export { ReactOverlayManager } from "./OverlayContainer"; 2 | export { Toolbar } from "./Toolbar"; 3 | export { ModifierButton } from "./ModifierButton"; 4 | export { ToolbarButton } from "./ToolbarButton"; 5 | export { VirtualKeyboard } from "./VirtualKeyboard"; 6 | export { KeyButton } from "./KeyButton"; 7 | export { ZoomControl } from "./ZoomControl"; 8 | export { PerformanceOverlay } from "./PerformanceOverlay"; 9 | export { useFullscreen } from "./useFullscreen"; 10 | export type { ModifierKeys, ToolbarCallbacks, ToolbarPosition } from "./types"; 11 | export type { FrameData, RenderStats, LayerStats } from "./PerformanceOverlay"; 12 | -------------------------------------------------------------------------------- /packages/driver/src/c/util.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include "util.h" 6 | 7 | void log_error(const char *fmt, ...) { 8 | va_list args; 9 | 10 | va_start(args, fmt); 11 | int length = vsnprintf(NULL, 0, fmt, args); 12 | va_end(args); 13 | 14 | char *buffer = (char *) malloc(length + 1); 15 | if (buffer == NULL) { 16 | return; 17 | } 18 | 19 | va_start(args, fmt); 20 | vsnprintf(buffer, length + 1, fmt, args); 21 | va_end(args); 22 | 23 | EM_ASM_({ 24 | console.error(UTF8ToString($0)); 25 | }, buffer); 26 | 27 | free(buffer); 28 | } 29 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "esModuleInterop": true, 5 | "jsx": "react-jsx", 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ES2022", 8 | "moduleResolution": "bundler", 9 | "noEmit": true, 10 | "resolveJsonModule": true, 11 | "rootDirs": [".", "./.react-router/types"], 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "target": "ES2022", 15 | "types": ["node", "vite/client", "@webgpu/types"], 16 | "verbatimModuleSyntax": true 17 | }, 18 | "exclude": ["functions/**/*"], 19 | "include": ["src", "**/.client/**/*", ".react-router/types/**/*"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /packages/web/functions/api/kv/_middleware.ts: -------------------------------------------------------------------------------- 1 | import * as jose from "jose"; 2 | 3 | const JWKS = jose.createRemoteJWKSet(new URL("https://pob-web.us.auth0.com/.well-known/jwks.json")); 4 | 5 | export async function onRequest(context) { 6 | const jwt = context.request.headers.get("Authorization")?.split("Bearer ")[1]; 7 | if (!jwt) { 8 | return new Response("Unauthorized", { status: 401 }); 9 | } 10 | 11 | try { 12 | const { payload } = await jose.jwtVerify(jwt, JWKS, { 13 | issuer: "https://pob-web.us.auth0.com/", 14 | audience: "https://pob.cool/api", 15 | }); 16 | context.data = { sub: payload.sub }; 17 | return await context.next(); 18 | } catch (e) { 19 | return new Response("Unauthorized", { status: 403 }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/driver/src/c/wasmfs/support.h: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Emscripten Authors. All rights reserved. 2 | // Emscripten is available under two separate licenses, the MIT license and the 3 | // University of Illinois/NCSA Open Source License. Both these licenses can be 4 | // found in the LICENSE file. 5 | 6 | #pragma once 7 | 8 | #include 9 | 10 | #ifndef NDEBUG 11 | // In debug builds show a message. 12 | namespace wasmfs { 13 | [[noreturn]] void 14 | handle_unreachable(const char* msg, const char* file, unsigned line); 15 | } 16 | #define WASMFS_UNREACHABLE(msg) \ 17 | wasmfs::handle_unreachable(msg, __FILE__, __LINE__) 18 | #else 19 | // In release builds trap in a compact manner. 20 | #define WASMFS_UNREACHABLE(msg) __builtin_trap() 21 | #endif 22 | -------------------------------------------------------------------------------- /.mise.pob-cool.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | sops = "latest" 3 | aws-cli = "latest" 4 | 5 | [env] 6 | _.file = ".env.pob-cool.yaml" 7 | R2_ENDPOINT_URL = "https://621480c42e70995622a2d0a86bb7751c.r2.cloudflarestorage.com" 8 | 9 | [tasks.r2] 10 | description = "aws s3 wrapper for Cloudflare R2" 11 | run = "aws s3 --endpoint-url=${R2_ENDPOINT_URL}" 12 | dir = "{{cwd}}" 13 | 14 | [tasks.sync] 15 | description = "Sync the packed distribution to Cloudflare R2" 16 | run = """ 17 | aws s3 sync --checksum-algorithm CRC32 --region auto --endpoint-url ${R2_ENDPOINT_URL} packages/packer/r2/games/{{option(name="game")}}/versions/{{option(name="tag")}} "s3://pob-web/games/{{option(name="game")}}/versions/{{option(name="tag")}}" 18 | """ 19 | 20 | [tasks.deploy] 21 | description = "Deploy the web package to Cloudflare Pages" 22 | run = "npm run deploy -w packages/web" 23 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/useFullscreen.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useFullscreen = () => { 4 | const [isFullscreen, setIsFullscreen] = useState(false); 5 | 6 | useEffect(() => { 7 | const handleFullscreenChange = () => { 8 | setIsFullscreen(!!document.fullscreenElement); 9 | }; 10 | 11 | document.addEventListener("fullscreenchange", handleFullscreenChange); 12 | return () => { 13 | document.removeEventListener("fullscreenchange", handleFullscreenChange); 14 | }; 15 | }, []); 16 | 17 | const toggleFullscreen = () => { 18 | if (!document.fullscreenElement) { 19 | document.documentElement.requestFullscreen(); 20 | } else { 21 | document.exitFullscreen(); 22 | } 23 | }; 24 | 25 | return { 26 | isFullscreen, 27 | toggleFullscreen, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/KeyButton.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useCallback } from "react"; 3 | 4 | interface KeyButtonProps { 5 | label: string; 6 | char: string; 7 | width?: string; 8 | callbacks: { onClick: (key: string) => void }; 9 | isActive?: boolean; 10 | } 11 | 12 | export const KeyButton: React.FC = ({ label, char, width, callbacks, isActive = false }) => { 13 | const executeAction = useCallback(() => { 14 | callbacks.onClick(char); 15 | }, [char, callbacks]); 16 | 17 | const baseClasses = "pw:btn pw:btn-md pw:h-10"; 18 | const variantClasses = isActive ? "pw:btn-primary" : "pw:btn-neutral pw:opacity-80"; 19 | 20 | return ( 21 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/driver/src/js/run.ts: -------------------------------------------------------------------------------- 1 | import { type Game, gameData } from "pob-game/src"; 2 | import { Driver } from "./driver"; 3 | 4 | (async () => { 5 | const versionPrefix = `${__ASSET_PREFIX__}/games/${__RUN_GAME__}/versions/${__RUN_VERSION__}`; 6 | console.log("Loading driver with assets:", versionPrefix); 7 | 8 | const driver = new Driver(__RUN_BUILD__, versionPrefix, { 9 | onError: error => console.error(error), 10 | onFrame: (_at, _time, _stats) => {}, 11 | onFetch: async (_url, _headers, _body) => { 12 | throw new Error("Fetch not implemented in shell"); 13 | }, 14 | onTitleChange: _title => {}, 15 | }); 16 | await driver.start({ 17 | userDirectory: gameData[__RUN_GAME__ as Game].userDirectory, 18 | cloudflareKvPrefix: "/api/kv/", 19 | cloudflareKvAccessToken: undefined, 20 | cloudflareKvUserNamespace: undefined, 21 | }); 22 | const window = document.querySelector("#window") as HTMLElement; 23 | if (window) { 24 | driver.attachToDOM(window); 25 | } 26 | })(); 27 | -------------------------------------------------------------------------------- /packages/web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pob.cool - Path of Building Web", 3 | "short_name": "pob.cool", 4 | "description": "A web version of Path of Building character planner for Path of Exile", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#1a1a1a", 8 | "theme_color": "#1a1a1a", 9 | "orientation": "any", 10 | "scope": "/", 11 | "lang": "en", 12 | "icons": [ 13 | { 14 | "src": "/favicon.png", 15 | "sizes": "128x128", 16 | "type": "image/png", 17 | "purpose": "any maskable" 18 | }, 19 | { 20 | "src": "/favicon.ico", 21 | "sizes": "any", 22 | "type": "image/x-icon" 23 | } 24 | ], 25 | "categories": ["games", "utilities"], 26 | "shortcuts": [ 27 | { 28 | "name": "Path of Exile 2", 29 | "url": "/poe2", 30 | "description": "Open Path of Exile 2 character planner" 31 | }, 32 | { 33 | "name": "Path of Exile 1", 34 | "url": "/poe1", 35 | "description": "Open Path of Exile 1 character planner" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pob-web", 3 | "private": true, 4 | "version": "0.27.5", 5 | "type": "module", 6 | "dependencies": { 7 | "@auth0/auth0-react": "^2.2.4", 8 | "@headlessui/react": "^2.2.0", 9 | "@react-router/node": "^7.1.3", 10 | "@react-router/fs-routes": "^7.1.3", 11 | "@sentry/react": "^10.5.0", 12 | "@heroicons/react": "^2.2.0", 13 | "isbot": "^5", 14 | "jose": "^5.3.0", 15 | "missionlog": "^1.8.8", 16 | "pob-driver": "*", 17 | "pob-game": "*", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1", 20 | "react-router": "^7.1.3", 21 | "react-use": "^17.6.0", 22 | "dayjs": "^1.11.13" 23 | }, 24 | "scripts": { 25 | "dev": "concurrently \"react-router dev\" \"wrangler pages dev . --compatibility-date=2024-05-11\"", 26 | "build": "react-router typegen && tsc && react-router build", 27 | "preview": "react-router preview", 28 | "deploy": "DEPLOYMENT=cloudflare npm run build && npm run deploy:pages", 29 | "deploy:pages": "wrangler pages deploy build/client --project-name pob-web" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Koji AGAWA 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/game/src/index.ts: -------------------------------------------------------------------------------- 1 | export const games = ["poe1", "poe2", "le"] as const; 2 | 3 | export type Game = (typeof games)[number]; 4 | type GameData = { 5 | name: string; 6 | repository: { owner: string; name: string }; 7 | userDirectory: string; 8 | cloudflareKvNamespace: string | undefined; 9 | }; 10 | 11 | export const gameData: Record = { 12 | poe1: { 13 | name: "Path of Exile 1", 14 | repository: { owner: "PathOfBuildingCommunity", name: "PathOfBuilding" }, 15 | userDirectory: "Path of Building", 16 | cloudflareKvNamespace: undefined, 17 | }, 18 | poe2: { 19 | name: "Path of Exile 2", 20 | repository: { owner: "PathOfBuildingCommunity", name: "PathOfBuilding-PoE2" }, 21 | userDirectory: "Path of Building (PoE2)", 22 | cloudflareKvNamespace: "poe2", 23 | }, 24 | le: { 25 | name: "Last Epoch", 26 | repository: { owner: "Musholic", name: "LastEpochPlanner" }, 27 | userDirectory: "Last Epoch Planner", 28 | cloudflareKvNamespace: "le", 29 | }, 30 | }; 31 | 32 | export function isGame(game: string): game is Game { 33 | return games.includes(game as Game); 34 | } 35 | -------------------------------------------------------------------------------- /hk.pkl: -------------------------------------------------------------------------------- 1 | amends "package://github.com/jdx/hk/releases/download/v1.2.0/hk@1.2.0#/Config.pkl" 2 | import "package://github.com/jdx/hk/releases/download/v1.2.0/hk@1.2.0#/Builtins.pkl" 3 | 4 | local linters = new Mapping { 5 | 6 | ["biome"] { 7 | glob = List("*.js", "*.jsx", "*.ts", "*.tsx") 8 | stage = glob 9 | check = "biome check {{ files }}" 10 | fix = "biome check --write {{ files }}" 11 | prefix = "npx" 12 | } 13 | ["tsc"] = (Builtins.tsc) { 14 | prefix = "npx" 15 | } 16 | 17 | ["pkl"] { 18 | glob = "*.pkl" 19 | check = "pkl eval {{files}} >/dev/null" 20 | prefix = "mise exec -- " 21 | } 22 | 23 | ["actionlint"] = (Builtins.actionlint) { 24 | prefix = "mise exec -- " 25 | } 26 | } 27 | 28 | hooks { 29 | ["pre-commit"] { 30 | fix = true 31 | stash = "patch-file" 32 | steps { 33 | ...linters 34 | } 35 | } 36 | ["fix"] { 37 | fix = true 38 | stash = "patch-file" 39 | steps = linters 40 | } 41 | ["check"] { 42 | steps = linters 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/driver/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pob-driver", 3 | "private": true, 4 | "version": "0.27.5", 5 | "type": "module", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "build": "npm run build:debug && npm run build:release && npm run make-index", 11 | "build:debug": "emcmake cmake -G Ninja -B build -S . -DCMAKE_BUILD_TYPE=Debug && EMCC_FORCE_STDLIBS=libc emmake ninja -C build", 12 | "build:release": "emcmake cmake -G Ninja -B build -S . -DCMAKE_BUILD_TYPE=Release && EMCC_FORCE_STDLIBS=libc emmake ninja -C build", 13 | "dev": "vite", 14 | "preview": "vite preview", 15 | "make-index": "node -e 'require(\"shelljs/global\"); const p = exec(\"npm prefix\", {silent: true}).stdout.trim(); const s = `node ${p}/node_modules/@zenfs/core/scripts/make-index.js`; exec(`${s} -o dist/index-release.json dist/release`); exec(`${s} -o dist/index-debug.json dist/debug`);'" 16 | }, 17 | "dependencies": { 18 | "@bokuweb/zstd-wasm": "^0.0.22", 19 | "@zenfs/archives": "^1.0.5", 20 | "@zenfs/core": "^1.11.3", 21 | "@zenfs/dom": "^1.1.5", 22 | "comlink": "^4.4.1", 23 | "dds": "*", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0", 26 | "react-icons": "^5.5.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/web/src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import { Log, LogLevel } from "missionlog"; 2 | 3 | export { tag } from "missionlog"; 4 | 5 | const logger = { 6 | [LogLevel.ERROR]: (tag, msg, params) => 7 | console.error(`%c${tag}%c`, "background:red;border-radius:5px;padding:0 4px;", "", msg, ...params), 8 | [LogLevel.WARN]: (tag, msg, params) => 9 | console.warn(`%c${tag}%c`, "color:black;background:yellow;border-radius:5px;padding:0 4px;", "", msg, ...params), 10 | [LogLevel.INFO]: (tag, msg, params) => 11 | console.info(`%c${tag}%c`, "background:green;border-radius:5px;padding:0 4px;", "", msg, ...params), 12 | [LogLevel.DEBUG]: (tag, msg, params) => 13 | console.debug(`%c${tag}%c`, "color:black;background:grey;border-radius:5px;padding:0 4px;", "", msg, ...params), 14 | [LogLevel.TRACE]: (tag, msg, params) => 15 | console.trace(`%c${tag}%c`, "color:black;background:cyan;border-radius:5px;padding:0 4px;", "", msg, ...params), 16 | } as Record void>; 17 | 18 | export const log = new Log().init( 19 | { 20 | pob: "DEBUG", 21 | vfs: "DEBUG", 22 | }, 23 | (level, tag, msg, params) => { 24 | logger[level as keyof typeof logger](tag, msg, params); 25 | }, 26 | ); 27 | -------------------------------------------------------------------------------- /packages/driver/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Path of Building Web 7 | 30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /packages/driver/src/js/event.ts: -------------------------------------------------------------------------------- 1 | export type VisibilityCallbacks = { 2 | onVisibilityChange: (visible: boolean) => void; 3 | }; 4 | 5 | export class EventHandler { 6 | private handleVisibilityChange: () => void; 7 | private preventDefault: (e: Event) => void; 8 | 9 | constructor( 10 | private el: HTMLElement, 11 | private callbacks: VisibilityCallbacks, 12 | ) { 13 | this.preventDefault = (e: Event) => e.preventDefault(); 14 | this.handleVisibilityChange = () => { 15 | this.callbacks.onVisibilityChange(this.el.ownerDocument.visibilityState === "visible"); 16 | }; 17 | 18 | el.ownerDocument.addEventListener("visibilitychange", this.handleVisibilityChange); 19 | el.addEventListener("contextmenu", this.preventDefault); 20 | el.addEventListener("copy", this.preventDefault); 21 | el.addEventListener("paste", this.preventDefault); 22 | 23 | el.focus(); 24 | } 25 | 26 | destroy() { 27 | this.el.ownerDocument.removeEventListener("visibilitychange", this.handleVisibilityChange); 28 | this.el.removeEventListener("contextmenu", this.preventDefault); 29 | this.el.removeEventListener("copy", this.preventDefault); 30 | this.el.removeEventListener("paste", this.preventDefault); 31 | } 32 | } 33 | 34 | export type { MouseState } from "./mouse-handler"; 35 | -------------------------------------------------------------------------------- /packages/driver/src/c/wasmfs/backend.h: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Emscripten Authors. All rights reserved. 2 | // Emscripten is available under two separate licenses, the MIT license and the 3 | // University of Illinois/NCSA Open Source License. Both these licenses can be 4 | // found in the LICENSE file. 5 | 6 | // This file defines the modular backend abstract class. 7 | // Other file system backends can use this to interface with the new file 8 | // system. Current Status: Work in Progress. See 9 | // https://github.com/emscripten-core/emscripten/issues/15041. 10 | 11 | #pragma once 12 | 13 | #include "file.h" 14 | 15 | namespace wasmfs { 16 | // A backend (or modular backend) provides a base for the new file system to 17 | // extend its storage capabilities. Files and directories will be represented 18 | // in the file system structure, but their underlying backing could exist in 19 | // persistent storage, another thread, etc. 20 | class Backend { 21 | 22 | public: 23 | virtual std::shared_ptr createFile(mode_t mode) = 0; 24 | virtual std::shared_ptr createDirectory(mode_t mode) = 0; 25 | virtual std::shared_ptr createSymlink(std::string target) = 0; 26 | 27 | virtual ~Backend() = default; 28 | }; 29 | 30 | typedef backend_t (*backend_constructor_t)(void*); 31 | } // namespace wasmfs 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@atty303/pob-web", 3 | "private": true, 4 | "version": "0.27.5", 5 | "workspaces": [ 6 | "packages/dds", 7 | "packages/driver", 8 | "packages/game", 9 | "packages/packer", 10 | "packages/web" 11 | ], 12 | "devDependencies": { 13 | "@biomejs/biome": "^1.9.4", 14 | "@bokuweb/zstd-wasm": "^0.0.22", 15 | "@cloudflare/workers-types": "^4.20250124.3", 16 | "@react-router/dev": "^7.1.3", 17 | "@tailwindcss/typography": "^0.5.16", 18 | "@tailwindcss/vite": "^4.0.0", 19 | "@types/adm-zip": "^0.5.7", 20 | "@types/emscripten": "^1.40.0", 21 | "@types/node": "^22.12.0", 22 | "@types/react": "^18.3.18", 23 | "@types/react-dom": "^18.3.5", 24 | "@types/shelljs": "^0.8.15", 25 | "@vitejs/plugin-react": "^5.0.0", 26 | "@webgpu/types": "^0.1.64", 27 | "adm-zip": "^0.5.12", 28 | "autoprefixer": "^10.4.19", 29 | "concurrently": "^8.2.2", 30 | "copyfiles": "^2.4.1", 31 | "daisyui": "^5.0.50", 32 | "dotenv": "^16.4.7", 33 | "image-size": "^1.2.0", 34 | "shelljs": "^0.8.5", 35 | "tailwindcss": "^4.1.12", 36 | "tsx": "^4.19.2", 37 | "typescript": "^5.7.3", 38 | "vite": "^6", 39 | "vite-plugin-babel": "^1.3.0", 40 | "vite-plugin-inspect": "^10.1.0", 41 | "vite-plugin-static-copy": "^2.2.0", 42 | "wrangler": "^3.106.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/web/functions/api/fetch.ts: -------------------------------------------------------------------------------- 1 | interface Env { 2 | KV: KVNamespace; 3 | } 4 | 5 | interface FetchRequest { 6 | url: string; 7 | body?: string; 8 | headers: Record; 9 | } 10 | 11 | export const onRequest: PagesFunction = async context => { 12 | const req: FetchRequest = await context.request.json(); 13 | try { 14 | let r: Request; 15 | if (req.body) { 16 | r = new Request(req.url, { 17 | method: "POST", 18 | body: req.body, 19 | headers: Object.assign({}, req.headers, { 20 | // "User-Agent": "pob.cool", 21 | }), 22 | }); 23 | } else { 24 | r = new Request(req.url, { 25 | method: "GET", 26 | headers: Object.assign({}, req.headers, { 27 | // "User-Agent": "pob.cool", 28 | }), 29 | }); 30 | } 31 | const rep = await fetch(r); 32 | 33 | const headers = {}; 34 | for (const [key, value] of rep.headers.entries()) { 35 | headers[key] = value; 36 | } 37 | 38 | return new Response( 39 | JSON.stringify({ 40 | body: await rep.text(), 41 | headers, 42 | status: rep.status, 43 | }), 44 | ); 45 | } catch (e) { 46 | return new Response( 47 | JSON.stringify({ 48 | body: undefined, 49 | headers: {}, 50 | error: e.message, 51 | }), 52 | ); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /packages/driver/vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import react from "@vitejs/plugin-react"; 4 | import { defineConfig, searchForWorkspaceRoot } from "vite"; 5 | import Inspect from "vite-plugin-inspect"; 6 | 7 | const packerR2Dir = path.resolve(__dirname, "../packer/r2"); 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig(({ mode }) => ({ 11 | logLevel: "info", 12 | server: { 13 | fs: { 14 | allow: [searchForWorkspaceRoot(process.cwd()), packerR2Dir], 15 | }, 16 | // Owner's Cloudflare Tunnel domain for mobile testing 17 | allowedHosts: ["local.pob.cool"], 18 | }, 19 | define: { 20 | __ASSET_PREFIX__: JSON.stringify( 21 | mode === "development" && process.env.POB_COOL_ASSET === undefined 22 | ? `/@fs/${packerR2Dir}` 23 | : "https://asset.pob.cool", 24 | ), 25 | __RUN_GAME__: JSON.stringify(process.env.RUN_GAME ?? "poe2"), 26 | __RUN_VERSION__: JSON.stringify(process.env.RUN_VERSION ?? "v0.8.0"), 27 | __RUN_BUILD__: JSON.stringify(process.env.RUN_BUILD ?? "release"), 28 | }, 29 | build: { 30 | sourcemap: true, 31 | }, 32 | worker: { 33 | format: "es", 34 | }, 35 | optimizeDeps: { 36 | exclude: ["@bokuweb/zstd-wasm"], 37 | esbuildOptions: { 38 | target: "es2020", 39 | }, 40 | }, 41 | plugins: [react(), tailwindcss(), Inspect()], 42 | })); 43 | -------------------------------------------------------------------------------- /packages/web/src/components/AuthButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ArrowRightEndOnRectangleIcon, 3 | ArrowRightStartOnRectangleIcon, 4 | UserCircleIcon, 5 | } from "@heroicons/react/24/solid"; 6 | import type React from "react"; 7 | 8 | interface AuthButtonProps { 9 | position: "top" | "bottom" | "left" | "right"; 10 | isLandscape: boolean; 11 | isLoading: boolean; 12 | isAuthenticated: boolean; 13 | userName?: string; 14 | onLogin: () => void; 15 | onLogout: () => void; 16 | } 17 | 18 | export const AuthButton: React.FC = ({ isLoading, isAuthenticated, userName, onLogin, onLogout }) => { 19 | if (isLoading) { 20 | return ( 21 | 24 | ); 25 | } else if (isAuthenticated) { 26 | return ( 27 | 35 | ); 36 | } else { 37 | return ( 38 | 46 | ); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /.env.pob-cool.yaml: -------------------------------------------------------------------------------- 1 | CLOUDFLARE_API_TOKEN: ENC[AES256_GCM,data:q5+avcnwU0u0mWg3xTUk4Ddqq6oTIcO1ey1eU/EOp49/ySrWj1VrzQ==,iv:HLlrSauv5MByfGkSEOBKEI3YGD6q4W7HbiLFg5fY/uw=,tag:xM92owE3KDwX/xqwQzzFrA==,type:str] 2 | AWS_ACCESS_KEY_ID: ENC[AES256_GCM,data:Y1frOVcrez/oedb4lsYt8v4Z01MVmfjs0aVlROR9okQ=,iv:B5sIObIc1dQBG9gp8sk1S4HvRj+ks9BcOKTlGpVGc+8=,tag:lSS3xE3Fx4bzX0tbqrklQA==,type:str] 3 | AWS_SECRET_ACCESS_KEY: ENC[AES256_GCM,data:vo8KaafFPx7d1cVjv05Ky5uEorecybFEZr2RCqDZsBwyy9ak9maHL/u66NnJVLufG4ZEvn9b+tZeIQfCqECVdA==,iv:nuUQO9Iqy9l8npd/SWJEkmLrzkGN6+Ag1IvukPposw4=,tag:u8vSr9InVdZPJrh8nypXiQ==,type:str] 4 | sops: 5 | age: 6 | - recipient: age1vpnvz7sc9c989gry7aa9qm6hehtuhxn3fzpg7kksgsuuker4u97srd88d4 7 | enc: | 8 | -----BEGIN AGE ENCRYPTED FILE----- 9 | YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxWnoxOURucjZERkNXM3FF 10 | UWRYK0pmcWcybkQ2SnJybFVTbWwzMm4veGwwCmZNS1Ixc1F2U2locDVRNlBXNzdy 11 | cStSQXBvTTFFS2RUSE9jSmJmbXNCUXcKLS0tIEM3c0NxYm9ic2traHd1T1B4SHlN 12 | ckhxcGZYL2JpUTZvTC9VaGZSZm5acm8K3XRevoHwak83FvIPklP3MY2CBDbl5exh 13 | WumQFggqVh1p+USpjwwIlnaGjajLPL5BDrtOQypc49h7P3SUypIAhw== 14 | -----END AGE ENCRYPTED FILE----- 15 | lastmodified: "2025-08-12T03:29:57Z" 16 | mac: ENC[AES256_GCM,data:eBJ2gI4Te2eOkHQZRgaI9Plu1nkyS0kpnsbHzzga6T49j6c4u0oEWKIeq7hoERSgQbhJn8pvLcBE02d6F0iQg5ZaQpf2PhgnXduSgOfsnrdJ4LkUN46a8Svi/oqGLC+Kvq5/a08jIo9hyz481yNRqYCGt1bNMguKhRx53fB/UTs=,iv:oLvBF+pfg5xUSC80L5GJEIxtqquX2w3jaedproX1PUA=,tag:hhynJlHM/rWlLyu7TG72Cg==,type:str] 17 | unencrypted_suffix: _unencrypted 18 | version: 3.10.2 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/create-github-app-token@v1 16 | id: app-token 17 | with: 18 | app-id: ${{ secrets.TOKEN_APP_ID }} 19 | private-key: ${{ secrets.TOKEN_PRIVATE_KEY }} 20 | 21 | - uses: googleapis/release-please-action@v4 22 | id: release-please 23 | with: 24 | token: ${{ steps.app-token.outputs.token }} 25 | config-file: .github/release-please-config.json 26 | manifest-file: .github/.release-please-manifest.json 27 | 28 | - uses: actions/checkout@v4 29 | with: 30 | submodules: true 31 | 32 | - name: Cache npm dependencies 33 | uses: actions/cache@v4 34 | with: 35 | key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} 36 | restore-keys: | 37 | ${{ runner.os }}-npm- 38 | path: | 39 | ~/.npm 40 | 41 | - uses: jdx/mise-action@v2 42 | 43 | - name: "Build" 44 | run: mise run web:build 45 | 46 | - name: Deploy 47 | if: ${{ steps.release-please.outputs.release_created }} 48 | run: npm run deploy -w packages/web 49 | env: 50 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 51 | CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }} 52 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 53 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 54 | VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 55 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/ModifierButton.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useCallback } from "react"; 3 | import type { ModifierKeys } from "./types"; 4 | 5 | interface ModifierButtonProps { 6 | modifierKey: keyof ModifierKeys; 7 | label: string; 8 | isActive: boolean; 9 | onToggle: (key: keyof ModifierKeys) => void; 10 | } 11 | 12 | export const ModifierButton: React.FC = ({ modifierKey, label, isActive, onToggle }) => { 13 | const handleClick = useCallback( 14 | (e: React.MouseEvent) => { 15 | e.preventDefault(); 16 | e.stopPropagation(); 17 | onToggle(modifierKey); 18 | }, 19 | [modifierKey, onToggle], 20 | ); 21 | 22 | const handleTouchEnd = useCallback( 23 | (e: React.TouchEvent) => { 24 | e.preventDefault(); 25 | e.stopPropagation(); 26 | onToggle(modifierKey); 27 | }, 28 | [modifierKey, onToggle], 29 | ); 30 | 31 | return ( 32 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /packages/web/functions/api/kv/[[name]].ts: -------------------------------------------------------------------------------- 1 | interface Env { 2 | KV: KVNamespace; 3 | } 4 | 5 | interface Metadata { 6 | dir: boolean; 7 | } 8 | 9 | export const onRequest: PagesFunction = async context => { 10 | const ns = context.request.headers.get("x-user-namespace"); 11 | const sub = context.data.sub; 12 | const path = Array.isArray(context.params.name) 13 | ? context.params.name.map(decodeURIComponent).join("/") 14 | : context.params.name 15 | ? decodeURIComponent(context.params.name) 16 | : undefined; 17 | 18 | if (!path) { 19 | const prefix = ns ? `user:${sub}:ns-vfs:${ns}:` : `user:${sub}:vfs:`; 20 | const l = await context.env.KV.list({ prefix }); 21 | const r = l.keys.map(k => ({ name: k.name.replace(prefix, ""), metadata: k.metadata })); 22 | return new Response(JSON.stringify(r)); 23 | } 24 | 25 | const key = ns ? `user:${sub}:ns-vfs:${ns}:${path}` : `user:${sub}:vfs:${path}`; 26 | switch (context.request.method) { 27 | case "HEAD": { 28 | const r = await context.env.KV.getWithMetadata(key, { type: "stream" }); 29 | if (!r) { 30 | return new Response(null, { status: 404 }); 31 | } 32 | return new Response(JSON.stringify(r.metadata), { headers: { "content-type": "application/json" } }); 33 | } 34 | case "GET": { 35 | const r = await context.env.KV.getWithMetadata(key, { type: "arrayBuffer" }); 36 | if (!r) { 37 | return new Response(null, { status: 404 }); 38 | } 39 | return new Response(r.value, { headers: { "x-metadata": JSON.stringify(r.metadata) } }); 40 | } 41 | case "PUT": { 42 | const metadata = JSON.parse(context.request.headers.get("x-metadata") || "{}"); 43 | const body = await context.request.arrayBuffer(); 44 | const data = new Uint8Array(body); 45 | await context.env.KV.put(key, data, { metadata }); 46 | return new Response(null, { status: 204 }); 47 | } 48 | case "DELETE": { 49 | await context.env.KV.delete(key); 50 | return new Response(null, { status: 204 }); 51 | } 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /packages/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from "node:path"; 2 | import { reactRouter } from "@react-router/dev/vite"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import { defineConfig, normalizePath, searchForWorkspaceRoot } from "vite"; 5 | import { viteStaticCopy } from "vite-plugin-static-copy"; 6 | 7 | const rootDir = path.resolve(__dirname, "../.."); 8 | const packerR2Dir = path.resolve(__dirname, "../packer/r2"); 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig(({ mode }) => ({ 12 | server: { 13 | host: true, 14 | proxy: { 15 | "/api": "http://localhost:8788", 16 | }, 17 | sourcemapIgnoreList(file) { 18 | return file.includes("node_modules") || file.includes("logger.ts"); 19 | }, 20 | fs: { 21 | allow: [searchForWorkspaceRoot(process.cwd()), rootDir], 22 | }, 23 | // Owner's Cloudflare Tunnel domain for mobile testing 24 | allowedHosts: ["local.pob.cool"], 25 | headers: { 26 | "Cache-Control": "no-cache, no-store, must-revalidate", 27 | Pragma: "no-cache", 28 | Expires: "0", 29 | }, 30 | }, 31 | build: { 32 | chunkSizeWarningLimit: 1024, 33 | sourcemap: true, 34 | ssr: false, 35 | }, 36 | define: { 37 | APP_VERSION: JSON.stringify(process.env.npm_package_version), 38 | __VERSION_URL__: JSON.stringify( 39 | mode === "development" && process.env.POB_COOL_ASSET === undefined 40 | ? `/@fs/${rootDir}/version.json` 41 | : "https://asset.pob.cool/version.json", 42 | ), 43 | __ASSET_PREFIX__: JSON.stringify( 44 | mode === "development" && process.env.POB_COOL_ASSET === undefined 45 | ? `/@fs/${packerR2Dir}` 46 | : "https://asset.pob.cool", 47 | ), 48 | }, 49 | worker: { 50 | format: "es", 51 | }, 52 | optimizeDeps: { 53 | exclude: ["@bokuweb/zstd-wasm"], 54 | esbuildOptions: { 55 | target: "es2020", 56 | }, 57 | }, 58 | plugins: [ 59 | reactRouter(), 60 | tailwindcss(), 61 | viteStaticCopy({ 62 | targets: [{ src: normalizePath(path.join(rootDir, "packages/driver/dist/*")), dest: "dist/" }], 63 | }), 64 | ], 65 | })); 66 | -------------------------------------------------------------------------------- /packages/driver/src/c/wasmfs/wasmfs.h: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Emscripten Authors. All rights reserved. 2 | // Emscripten is available under two separate licenses, the MIT license and the 3 | // University of Illinois/NCSA Open Source License. Both these licenses can be 4 | // found in the LICENSE file. 5 | // This file defines the global state of the new file system. 6 | // Current Status: Work in Progress. 7 | // See https://github.com/emscripten-core/emscripten/issues/15041. 8 | 9 | #pragma once 10 | 11 | #include "backend.h" 12 | #include "file.h" 13 | #include "file_table.h" 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | #include 20 | 21 | namespace wasmfs { 22 | 23 | class WasmFS { 24 | 25 | std::vector> backendTable; 26 | FileTable fileTable; 27 | std::shared_ptr rootDirectory; 28 | std::shared_ptr cwd; 29 | std::mutex mutex; 30 | 31 | // Private method to initialize root directory once. 32 | // Initializes default directories including dev/stdin, dev/stdout, 33 | // dev/stderr. Refers to the same std streams in the open file table. 34 | std::shared_ptr initRootDirectory(); 35 | 36 | // Initialize files specified by --preload-file option. 37 | void preloadFiles(); 38 | 39 | public: 40 | WasmFS(); 41 | ~WasmFS(); 42 | 43 | FileTable& getFileTable() { return fileTable; } 44 | 45 | std::shared_ptr getRootDirectory() { return rootDirectory; }; 46 | 47 | std::shared_ptr getCWD() { 48 | const std::lock_guard lock(mutex); 49 | return cwd; 50 | }; 51 | 52 | void setCWD(std::shared_ptr directory) { 53 | const std::lock_guard lock(mutex); 54 | cwd = directory; 55 | }; 56 | 57 | backend_t addBackend(std::unique_ptr backend) { 58 | const std::lock_guard lock(mutex); 59 | backendTable.push_back(std::move(backend)); 60 | return backendTable.back().get(); 61 | } 62 | }; 63 | 64 | // Global state instance. 65 | extern WasmFS wasmFS; 66 | 67 | } // namespace wasmfs 68 | -------------------------------------------------------------------------------- /packages/dds/src/storage.ts: -------------------------------------------------------------------------------- 1 | import { Format } from "./format"; 2 | 3 | export class StorageLinear { 4 | readonly blockSize: number; 5 | readonly blockExtent: [number, number, number]; 6 | 7 | constructor( 8 | readonly format: Format, 9 | readonly _extent: [number, number, number], 10 | readonly layers: number, 11 | readonly faces: number, 12 | readonly levels: number, 13 | ) { 14 | this.blockSize = Format.blockSize(this.format); 15 | this.blockExtent = Format.blockExtent(this.format); 16 | } 17 | 18 | blockCount(level: number) { 19 | const e = this.extent(level); 20 | return [ 21 | ceilMultiple(e[0], this.blockExtent[0]) / this.blockExtent[0], 22 | ceilMultiple(e[1], this.blockExtent[1]) / this.blockExtent[1], 23 | ceilMultiple(e[2], this.blockExtent[2]) / this.blockExtent[2], 24 | ] as const; 25 | } 26 | 27 | extent(level: number) { 28 | return [ 29 | Math.max(this._extent[0] >> level, 1), 30 | Math.max(this._extent[1] >> level, 1), 31 | Math.max(this._extent[2] >> level, 1), 32 | ] as const; 33 | } 34 | 35 | levelSize(level: number) { 36 | const c = this.blockCount(level); 37 | return this.blockSize * c[0] * c[1] * c[2]; 38 | } 39 | 40 | faceSize(baseLevel: number, maxLevel: number) { 41 | let size = 0; 42 | for (let level = baseLevel; level <= maxLevel; level++) { 43 | size += this.levelSize(level); 44 | } 45 | return size; 46 | } 47 | 48 | layerSize(baseFace: number, maxFace: number, baseLevel: number, maxLevel: number) { 49 | return this.faceSize(baseLevel, maxLevel) * (maxFace - baseFace + 1); 50 | } 51 | 52 | baseOffset(layer: number, face: number, level: number) { 53 | const layerSize = this.layerSize(0, this.faces - 1, 0, this.levels - 1); 54 | const faceSize = this.faceSize(0, this.levels - 1); 55 | 56 | let baseOffset = layerSize * layer + faceSize * face; 57 | for (let i = 0; i < level; i++) { 58 | baseOffset += this.levelSize(i); 59 | } 60 | return baseOffset; 61 | } 62 | } 63 | 64 | function ceilMultiple(value: number, multiple: number) { 65 | return Math.ceil(value / multiple) * multiple; 66 | } 67 | -------------------------------------------------------------------------------- /packages/driver/src/c/wasmfs/nodefs.h: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Emscripten Authors. All rights reserved. 2 | // Emscripten is available under two separate licenses, the MIT license and the 3 | // University of Illinois/NCSA Open Source License. Both these licenses can be 4 | // found in the LICENSE file. 5 | 6 | #include // for mode_t 7 | 8 | extern "C" { 9 | 10 | // These helper functions are defined in library_wasmfs_node.js. 11 | 12 | // Fill `entries` and return 0 or an error code. 13 | int _wasmfs_node_readdir(const char* path, void* entries 14 | /* std::vector*/); 15 | // Write `mode` and return 0 or an error code. 16 | int _wasmfs_node_get_mode(const char* path, mode_t* mode); 17 | 18 | // Write `size` and return 0 or an error code. 19 | int _wasmfs_node_stat_size(const char* path, uint32_t* size); 20 | int _wasmfs_node_fstat_size(int fd, uint32_t* size); 21 | 22 | // Create a new file system entry and return 0 or an error code. 23 | int _wasmfs_node_insert_file(const char* path, mode_t mode); 24 | int _wasmfs_node_insert_directory(const char* path, mode_t mode); 25 | 26 | // Unlink the given file and return 0 or an error code. 27 | int _wasmfs_node_unlink(const char* path); 28 | int _wasmfs_node_rmdir(const char* path); 29 | 30 | // Open the file and return the underlying file descriptor. 31 | [[nodiscard]] int _wasmfs_node_open(const char* path, const char* mode); 32 | 33 | // Close the underlying file descriptor. 34 | [[nodiscard]] int _wasmfs_node_close(int fd); 35 | 36 | // Read up to `size` bytes into `buf` from position `pos` in the file, writing 37 | // the number of bytes read to `nread`. Return 0 on success or an error code. 38 | int _wasmfs_node_read( 39 | int fd, void* buf, uint32_t len, uint32_t pos, uint32_t* nread); 40 | 41 | // Write up to `size` bytes from `buf` at position `pos` in the file, writing 42 | // the number of bytes written to `nread`. Return 0 on success or an error code. 43 | int _wasmfs_node_write( 44 | int fd, const void* buf, uint32_t len, uint32_t pos, uint32_t* nwritten); 45 | 46 | int _wasmfs_node_rename(const char *oldPath, const char *newPath); 47 | 48 | int _wasmfs_node_truncate(const char* path, uint32_t size); 49 | int _wasmfs_node_ftruncate(int fd, uint32_t size); 50 | 51 | } 52 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "24" 3 | emsdk = "4.0.11" 4 | cmake = "latest" 5 | ninja = "latest" 6 | 7 | hk = "latest" 8 | pkl = "latest" 9 | actionlint = "latest" 10 | 11 | [tasks.install] 12 | run = "npm install" 13 | sources = [ 14 | "package.json", 15 | "package-lock.json" 16 | ] 17 | outputs = { auto = true } 18 | 19 | [tasks.pack] 20 | description = "Pack the distribution from upstream repository" 21 | run = """ 22 | npm run -w packages/packer pack {{option(name="tag")}} {{option(name="game")}} clone 23 | """ 24 | 25 | [tasks.print-game-data] 26 | description = "Print game data" 27 | run = """ 28 | npx tsx -e 'import {gameData} from "pob-game/src"; console.log(JSON.stringify(gameData))' 29 | """ 30 | 31 | [tasks."driver:build"] 32 | description = "Build the driver package" 33 | run = "npm run -w packages/driver build" 34 | sources = [ 35 | "packages/driver/*.lua", 36 | "packages/driver/CMakeLists.txt", 37 | "packages/driver/*.cmake", 38 | "packages/driver/*.json", 39 | "packages/driver/src/c/**/*", 40 | "vendor/lua/**/*", 41 | "vendor/luautf8/**/*", 42 | ] 43 | outputs = [ "packages/driver/build/**/*" ] 44 | depends = ["install"] 45 | 46 | [tasks."driver:dev"] 47 | description = "Start dev server for driver" 48 | usage = """ 49 | flag "--game " { 50 | help "Game to run" 51 | choices "poe1" "poe2" "le" 52 | } 53 | flag "--version " help="Game version to run" 54 | flag "--build " { 55 | help "Build type to run" 56 | choices "release" "debug" 57 | default "release" 58 | } 59 | flag "--pob-cool-asset" help="Use https://asset.pob.cool asset instead of local packed assets" 60 | """ 61 | run = """ 62 | export RUN_GAME="${usage_game?}" 63 | export RUN_VERSION="${usage_version?}" 64 | export RUN_BUILD="${usage_build?}" 65 | [ -n "$usage_pob_cool_asset" ] && export POB_COOL_ASSET=true 66 | 67 | npm run -w packages/driver dev 68 | """ 69 | depends = ["driver:build"] 70 | 71 | [tasks."web:build"] 72 | description = "Build the web package" 73 | run = "npm run -w packages/web build" 74 | depends = ["driver:build"] 75 | 76 | [tasks."web:dev"] 77 | description = "Start dev server for web" 78 | usage = """ 79 | flag "--pob-cool-asset" help="Use https://asset.pob.cool asset instead of local packed assets" 80 | """ 81 | run = """ 82 | [ -n "$usage_pob_cool_asset" ] && export POB_COOL_ASSET=true 83 | npm run -w packages/web dev 84 | """ 85 | depends = ["driver:build"] 86 | -------------------------------------------------------------------------------- /packages/web/src/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "node:stream"; 2 | 3 | import { createReadableStreamFromReadable } from "@react-router/node"; 4 | import { isbot } from "isbot"; 5 | import type { RenderToPipeableStreamOptions } from "react-dom/server"; 6 | import { renderToPipeableStream } from "react-dom/server"; 7 | import type { AppLoadContext, EntryContext } from "react-router"; 8 | import { ServerRouter } from "react-router"; 9 | 10 | export const streamTimeout = 5_000; 11 | 12 | export default function handleRequest( 13 | request: Request, 14 | responseStatusCode: number, 15 | responseHeaders: Headers, 16 | routerContext: EntryContext, 17 | loadContext: AppLoadContext, 18 | ) { 19 | let statusCode = responseStatusCode; 20 | return new Promise((resolve, reject) => { 21 | let shellRendered = false; 22 | const userAgent = request.headers.get("user-agent"); 23 | 24 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 25 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 26 | const readyOption: keyof RenderToPipeableStreamOptions = 27 | (userAgent && isbot(userAgent)) || routerContext.isSpaMode ? "onAllReady" : "onShellReady"; 28 | 29 | const { pipe, abort } = renderToPipeableStream(, { 30 | [readyOption]() { 31 | shellRendered = true; 32 | const body = new PassThrough(); 33 | const stream = createReadableStreamFromReadable(body); 34 | 35 | responseHeaders.set("Content-Type", "text/html"); 36 | 37 | resolve( 38 | new Response(stream, { 39 | headers: responseHeaders, 40 | status: statusCode, 41 | }), 42 | ); 43 | 44 | pipe(body); 45 | }, 46 | onShellError(error: unknown) { 47 | reject(error); 48 | }, 49 | onError(error: unknown) { 50 | statusCode = 500; 51 | // Log streaming rendering errors from inside the shell. Don't log 52 | // errors encountered during initial shell rendering since they'll 53 | // reject and get logged in handleDocumentRequest. 54 | if (shellRendered) { 55 | console.error(error); 56 | } 57 | }, 58 | }); 59 | 60 | // Abort the rendering stream after the `streamTimeout` so it has time to 61 | // flush down the rejected boundaries 62 | setTimeout(abort, streamTimeout + 1000); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /packages/web/src/components/PoBController.tsx: -------------------------------------------------------------------------------- 1 | import type { Driver } from "pob-driver/src/js/driver"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import * as use from "react-use"; 4 | import type { Games } from "../routes/_game"; 5 | import { HelpButton } from "./HelpButton"; 6 | import { HelpDialog } from "./HelpDialog"; 7 | import PoBWindow from "./PoBWindow"; 8 | import { SettingsButton } from "./SettingsButton"; 9 | import { SettingsDialog } from "./SettingsDialog"; 10 | 11 | const { useTitle } = use; 12 | 13 | export default function PoBController(p: { game: keyof Games; version: string; isHead: boolean }) { 14 | const [title, setTitle] = useState(); 15 | useTitle(title ?? "pob.cool"); 16 | 17 | const container = useRef(null); 18 | const driverRef = useRef(null); 19 | const settingsDialogRef = useRef(null); 20 | 21 | const [performanceVisible, setPerformanceVisible] = useState(false); 22 | const [helpDialogOpen, setHelpDialogOpen] = useState(false); 23 | 24 | const ToolbarComponents = ({ 25 | position, 26 | isLandscape, 27 | }: { position: "top" | "bottom" | "left" | "right"; isLandscape: boolean }) => { 28 | return ( 29 | <> 30 | settingsDialogRef.current?.showModal()} 34 | /> 35 | setHelpDialogOpen(true)} /> 36 | 37 | ); 38 | }; 39 | 40 | return ( 41 |
45 | {}} 49 | onTitleChange={setTitle} 50 | onLayerVisibilityCallbackReady={() => {}} 51 | onDriverReady={driver => { 52 | driverRef.current = driver; 53 | }} 54 | toolbarComponent={ToolbarComponents} 55 | /> 56 | 57 | { 62 | const newValue = !performanceVisible; 63 | setPerformanceVisible(newValue); 64 | driverRef.current?.setPerformanceVisible(newValue); 65 | }} 66 | /> 67 | 68 | setHelpDialogOpen(false)} /> 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /packages/web/src/components/ErrorDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | interface ErrorDialogProps { 4 | error: unknown; 5 | onReload: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | export default function ErrorDialog({ error, onReload, onClose }: ErrorDialogProps) { 10 | const [copied, setCopied] = useState(false); 11 | 12 | const message = error instanceof Error ? error.message : String(error); 13 | const stack = error instanceof Error ? error.stack : ""; 14 | 15 | const fullErrorText = stack ? `${message}\n\nStack Trace:\n${stack}` : message; 16 | 17 | const handleCopy = async () => { 18 | try { 19 | await navigator.clipboard.writeText(fullErrorText); 20 | setCopied(true); 21 | setTimeout(() => setCopied(false), 2000); 22 | } catch (err) { 23 | console.error("Failed to copy:", err); 24 | } 25 | }; 26 | 27 | return ( 28 | 29 |
30 | 33 | 34 |

Critical Error Occurred

35 | 36 |
37 |
38 |

Error Message:

39 |
40 |
{message}
41 |
42 |
43 | 44 | {stack && ( 45 |
46 |

Stack Trace:

47 |
48 |
{stack}
49 |
50 |
51 | )} 52 |
53 | 54 |
55 | 58 | 59 |
60 | 63 | 66 |
67 |
68 |
69 | 70 | {/* biome-ignore lint/a11y/useKeyWithClickEvents: backdrop is for mouse only */} 71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | pob-web is a browser-based version of Path of Building (PoB), a character build planner for Path of Exile. It runs the original PoB Lua code in the browser using WebAssembly. 8 | 9 | ## Architecture 10 | 11 | The project uses a monorepo structure with npm workspaces: 12 | 13 | - **packages/dds**: Microsoft DirectDraw Surface (.DDS) file parser 14 | - **packages/driver**: PoB driver that emulates the desktop PoB window environment using vanilla JS and WebGL 15 | - **packages/game**: Game data definitions 16 | - **packages/packer**: Tool to package upstream PoB releases for browser use 17 | - **packages/web**: React web application that hosts the driver 18 | 19 | Key architecture points: 20 | - Original PoB Lua code runs via a custom Lua 5.2 interpreter compiled to WebAssembly using Emscripten 21 | - SimpleGraphic module is reimplemented in C to bridge with the JS driver 22 | - WebGL is used for rendering 23 | - Builds are stored in browser localStorage, with cloud storage available for logged-in users 24 | 25 | ## Development Commands 26 | 27 | ### Prerequisites 28 | ```bash 29 | # Install mise (version manager) 30 | # Install dependencies 31 | mise install 32 | hk install --mise 33 | 34 | # Clone submodules (required for vendor/lua) 35 | git submodule init 36 | git submodule update 37 | ``` 38 | 39 | ### Core Development Tasks 40 | ```bash 41 | # Pack upstream PoB assets (required before first run) 42 | mise run pack --game poe2 --tag v0.8.0 43 | 44 | # Run driver development server 45 | mise run driver:dev --game poe2 --version v0.8.0 46 | 47 | # Run web app development server 48 | mise run web:dev 49 | 50 | # Build driver 51 | mise run driver:build 52 | 53 | # Build web app 54 | mise run web:build 55 | ``` 56 | 57 | ### Linting 58 | ```bash 59 | hk fix --all 60 | ``` 61 | 62 | ## Development Workflow 63 | 64 | 1. Before starting development, pack the upstream PoB assets for the version you want to work with 65 | 2. The driver must be built before running the web app 66 | 3. Use `mise run driver:dev` for driver-only development 67 | 4. Use `mise run web:dev` for full web application development 68 | 5. The project uses Biome for code formatting and linting 69 | 70 | ## Important Notes 71 | 72 | - Network access goes through a CORS proxy 73 | - POESESSID cookies are rejected for security 74 | - No modifications are made to the original PoB code, only behavioral changes through the driver 75 | - The project supports multiple games: poe1, poe2, and Last Epoch (le) 76 | 77 | ## Driver Overlay Components 78 | 79 | The driver includes React overlay components for mobile interface elements (virtual keyboard, zoom controls, toolbar buttons). These components use scoped TailwindCSS styling: 80 | 81 | - **CSS Scoping**: All TailwindCSS classes in overlay components must use the `pw:` prefix 82 | - **Example**: Use `pw:absolute pw:z-50 pw:p-3` instead of `absolute z-50 p-3` 83 | - **Configuration**: TailwindCSS is configured with `@theme { --prefix: pw:; }` in `packages/driver/src/js/overlay/overlay.css` 84 | - **Browser Compatibility**: The `pw:` prefix approach ensures compatibility with older browsers (including Amazon Silk Browser) that don't support CSS layers 85 | 86 | When working with overlay components, always use the `pw:` prefix for TailwindCSS classes to maintain proper CSS scoping and prevent conflicts with external stylesheets. 87 | -------------------------------------------------------------------------------- /packages/driver/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.28) 2 | 3 | project("driver") 4 | 5 | STRING (TOLOWER "${CMAKE_BUILD_TYPE}" CMAKE_BUILD_TYPE_LOWER) 6 | 7 | add_custom_command( 8 | OUTPUT ${CMAKE_BINARY_DIR}/boot.c 9 | COMMAND ${CMAKE_COMMAND} -E echo "Writing boot.lua to boot.c" 10 | COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/boot.lua ${CMAKE_BINARY_DIR}/boot.lua 11 | COMMAND ${CMAKE_COMMAND} ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_SOURCE_DIR}/gen_boot_c.cmake 12 | DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/boot.lua 13 | ) 14 | 15 | file(GLOB_RECURSE LUA_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../vendor/lua/*.c) 16 | list(REMOVE_ITEM LUA_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../../vendor/lua/lua.c) 17 | include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../../vendor/lua) 18 | 19 | add_compile_options("-flto" "-g" "-gsource-map" "-sUSE_ZLIB" "-DLUA_USE_DLOPEN") 20 | if(CMAKE_BUILD_TYPE STREQUAL "Debug") 21 | add_compile_options("-O0" "-g3") 22 | endif() 23 | if(CMAKE_BUILD_TYPE STREQUAL "Release") 24 | add_compile_options("-O3") 25 | endif() 26 | set(CMAKE_EXECUTABLE_SUFFIX ".mjs") 27 | 28 | add_executable(${PROJECT_NAME} 29 | ${LUA_SOURCES} 30 | ${CMAKE_BINARY_DIR}/boot.c 31 | src/c/driver.c 32 | src/c/draw.c 33 | src/c/draw.h 34 | src/c/image.c 35 | src/c/image.h 36 | src/c/fs.c 37 | src/c/fs.h 38 | src/c/util.c 39 | src/c/util.h 40 | src/c/wasmfs/nodefs.cpp 41 | src/c/wasmfs/nodefs.h 42 | src/c/wasmfs/nodefs_js.cpp 43 | src/c/sub.c 44 | src/c/sub.h 45 | src/c/lcurl.c 46 | src/c/lcurl.h 47 | ) 48 | 49 | set(DRIVER_LINK_FLAGS 50 | "-flto" 51 | "--no-entry" 52 | "--emit-symbol-map" 53 | "--profiling-funcs" 54 | "-sUSE_ZLIB" 55 | "-sMODULARIZE" 56 | "-sSTACK_SIZE=1MB" 57 | "-sASYNCIFY" 58 | "-sASYNCIFY_STACK_SIZE=131072" 59 | "-sENVIRONMENT=worker" 60 | "-sALLOW_MEMORY_GROWTH" 61 | "-sWASMFS" 62 | "-sSTRICT" 63 | "-sINCOMING_MODULE_JS_API=[print,printErr]" 64 | "-sEXPORTED_FUNCTIONS=[_malloc,_free,_init,_start,_on_frame,_on_key_down,_on_key_up,_on_char,_on_download_page_result,_on_subscript_finished,_load_build_from_code]" 65 | "-sEXPORTED_RUNTIME_METHODS=cwrap,ccall,ERRNO_CODES,setValue,HEAPU8,Asyncify" 66 | "-sASYNCIFY_IMPORTS=js_wasmfs_node_read" 67 | "-sMAIN_MODULE" 68 | "-sERROR_ON_UNDEFINED_SYMBOLS=0" 69 | ) 70 | if(CMAKE_BUILD_TYPE STREQUAL "Debug") 71 | set(DRIVER_LINK_FLAGS "${DRIVER_LINK_FLAGS}" "-sASSERTIONS=1" "-sSAFE_HEAP=1" "-sSTACK_OVERFLOW_CHECK=2") 72 | endif() 73 | target_link_options(${PROJECT_NAME} PRIVATE ${DRIVER_LINK_FLAGS}) 74 | 75 | set_target_properties(${PROJECT_NAME} 76 | PROPERTIES 77 | RUNTIME_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/dist/${CMAKE_BUILD_TYPE_LOWER} 78 | ) 79 | 80 | 81 | # Add for building `vendor/luautf8` as a SIDE_MODULE and outputting `luautf8.so` 82 | add_executable(luautf8 83 | ${CMAKE_CURRENT_SOURCE_DIR}/../../vendor/luautf8/lutf8lib.c 84 | ) 85 | 86 | set_target_properties(luautf8 87 | PROPERTIES 88 | RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/dist/${CMAKE_BUILD_TYPE_LOWER} 89 | OUTPUT_NAME "lua-utf8" 90 | SUFFIX ".wasm" 91 | ) 92 | 93 | target_compile_options(luautf8 PRIVATE 94 | "-flto" "-g" "-gsource-map" "-sSIDE_MODULE" 95 | ) 96 | 97 | target_link_options(luautf8 PRIVATE 98 | "-flto" 99 | "-sSIDE_MODULE" 100 | "-sSTRICT" 101 | ) 102 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useCallback, useMemo, useState } from "react"; 3 | import { CiKeyboard } from "react-icons/ci"; 4 | import { HiMagnifyingGlass } from "react-icons/hi2"; 5 | import { MdFullscreen, MdFullscreenExit } from "react-icons/md"; 6 | import { PiCursorThin } from "react-icons/pi"; 7 | import { ToolbarButton } from "./ToolbarButton"; 8 | import { ZoomControl } from "./ZoomControl"; 9 | import type { ToolbarCallbacks, ToolbarPosition } from "./types"; 10 | import { useFullscreen } from "./useFullscreen"; 11 | 12 | interface ToolbarProps { 13 | callbacks: ToolbarCallbacks; 14 | position: ToolbarPosition; 15 | isLandscape: boolean; 16 | panModeEnabled: boolean; 17 | keyboardVisible: boolean; 18 | performanceVisible?: boolean; 19 | currentZoom?: number; 20 | currentCanvasSize?: { width: number; height: number }; 21 | isFixedSize?: boolean; 22 | externalComponent?: React.ComponentType<{ position: ToolbarPosition; isLandscape: boolean }>; 23 | } 24 | 25 | export const Toolbar: React.FC = ({ 26 | callbacks, 27 | position, 28 | isLandscape, 29 | panModeEnabled, 30 | keyboardVisible, 31 | performanceVisible = false, 32 | currentZoom = 1.0, 33 | currentCanvasSize = { width: 1520, height: 800 }, 34 | isFixedSize = false, 35 | externalComponent: ExternalComponent, 36 | }) => { 37 | const [zoomControlVisible, setZoomControlVisible] = useState(false); 38 | const { isFullscreen, toggleFullscreen } = useFullscreen(); 39 | 40 | const handlePanModeToggle = useCallback(() => { 41 | callbacks.onPanModeToggle(!panModeEnabled); 42 | }, [callbacks, panModeEnabled]); 43 | 44 | const handleZoomToggle = useCallback(() => { 45 | setZoomControlVisible(prev => !prev); 46 | }, []); 47 | 48 | const handleFullscreenToggle = useCallback(() => { 49 | toggleFullscreen(); 50 | callbacks.onFullscreenToggle(); 51 | }, [toggleFullscreen, callbacks]); 52 | 53 | const containerClasses = `pw:navbar pw:bg-base-200/95 pw:shadow-lg pw:select-none pw:relative pw:gap-1 ${ 54 | isLandscape ? "pw:flex-col pw:justify-center pw:h-full" : "pw:flex-row pw:justify-center pw:w-full" 55 | }`; 56 | 57 | const fullscreenIcon = isFullscreen ? : ; 58 | const fullscreenTooltip = isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"; 59 | 60 | return ( 61 |
62 | {ExternalComponent &&
} 63 | 64 | } 66 | tooltip="Toggle Pan Tool" 67 | onClick={handlePanModeToggle} 68 | isActive={panModeEnabled} 69 | /> 70 | 71 | } 73 | tooltip="Toggle Virtual Keyboard" 74 | onClick={callbacks.onKeyboardToggle} 75 | isActive={keyboardVisible} 76 | /> 77 | 78 | } 80 | tooltip="Zoom Controls" 81 | onClick={handleZoomToggle} 82 | isActive={zoomControlVisible} 83 | /> 84 | 85 | 91 | 92 | {ExternalComponent &&
} 93 | 94 | {ExternalComponent && } 95 | 96 | 109 |
110 | ); 111 | }; 112 | -------------------------------------------------------------------------------- /packages/web/src/components/HelpDialog.tsx: -------------------------------------------------------------------------------- 1 | import { XMarkIcon } from "@heroicons/react/24/solid"; 2 | import type React from "react"; 3 | 4 | interface HelpDialogProps { 5 | isOpen: boolean; 6 | onClose: () => void; 7 | } 8 | 9 | export const HelpDialog: React.FC = ({ isOpen, onClose }) => { 10 | if (!isOpen) return null; 11 | 12 | return ( 13 |
14 |
15 | 22 | 23 |

Controls & Navigation

24 | 25 |
26 |
27 |

Touch Controls

28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
OperationPointer Mode OFFPointer Mode ON
Left ClickSingle tap-
Right ClickLong press (300ms)-
Pointer Move-Single finger movement
DragSingle finger movement-
WheelTwo finger vertical drag-
ZoomTwo finger pinchTwo finger pinch
Pan CanvasThree finger dragThree finger drag
75 |
76 |

77 | Desktop controls follow standard mouse/keyboard conventions. Pointer tool change left click behavior to 78 | pan canvas on desktop. 79 |

80 |
81 | 82 |
83 |

Tips

84 |
    85 |
  • 86 | On mobile, use Pointer Mode for precise cursor control 87 |
  • 88 |
  • Use landscape mode on mobile for more screen space
  • 89 |
  • On iOS, you can hide the browser toolbar in Safari settings for more screen space
  • 90 |
  • Installing as PWA on mobile provides full screen experience without browser UI
  • 91 |
92 |
93 |
94 |
95 |
e.key === "Escape" && onClose()} 99 | role="button" 100 | tabIndex={0} 101 | /> 102 |
103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /packages/driver/boot.lua: -------------------------------------------------------------------------------- 1 | -- pob-web: Path of Building Web 2 | 3 | package.path = package.path .. ";/app/root/lua/?.lua;/app/root/lua/?/init.lua" 4 | 5 | unpack = table.unpack 6 | loadstring = load 7 | 8 | bit = { 9 | lshift = bit32.lshift, 10 | rshift = bit32.rshift, 11 | band = bit32.band, 12 | bor = bit32.bor, 13 | bxor = bit32.bxor, 14 | bnot = bit32.bnot, 15 | } 16 | 17 | if not setfenv then -- Lua 5.2 18 | -- based on http://lua-users.org/lists/lua-l/2010-06/msg00314.html 19 | -- this assumes f is a function 20 | local function findenv(f) 21 | local level = 1 22 | repeat 23 | local name, value = debug.getupvalue(f, level) 24 | if name == '_ENV' then return level, value end 25 | level = level + 1 26 | until name == nil 27 | return nil end 28 | getfenv = function (f) return(select(2, findenv(f)) or _G) end 29 | setfenv = function (f, t) 30 | local level = findenv(f) 31 | if level then debug.setupvalue(f, level, t) end 32 | return f end 33 | end 34 | 35 | arg = {} 36 | 37 | jit = { 38 | opt = { 39 | start = function() end, 40 | stop = function() end, 41 | } 42 | } 43 | 44 | -- Rendering 45 | function RenderInit() 46 | end 47 | function SetClearColor(r, g, b, a) 48 | end 49 | function StripEscapes(text) 50 | return text:gsub("%^%d", ""):gsub("%^x%x%x%x%x%x%x", "") 51 | end 52 | function GetAsyncCount() 53 | return 0 54 | end 55 | 56 | -- General Functions 57 | function SetCursorPos(x, y) 58 | end 59 | function ShowCursor(doShow) 60 | end 61 | function GetScriptPath() 62 | return "." 63 | end 64 | function GetRuntimePath() 65 | return "" 66 | end 67 | function GetUserPath() 68 | return "/app/user" 69 | end 70 | function SetWorkDir(path) 71 | print("SetWorkDir: " .. path) 72 | end 73 | function GetWorkDir() 74 | return "" 75 | end 76 | function LoadModule(fileName, ...) 77 | if not fileName:match("%.lua") then 78 | fileName = fileName .. ".lua" 79 | end 80 | local func, err = loadfile(fileName) 81 | if func then 82 | return func(...) 83 | else 84 | error("LoadModule() error loading '" .. fileName .. "': " .. err) 85 | end 86 | end 87 | function PLoadModule(fileName, ...) 88 | if not fileName:match("%.lua") then 89 | fileName = fileName .. ".lua" 90 | end 91 | local func, err = loadfile(fileName) 92 | if func then 93 | return PCall(func, ...) 94 | else 95 | error("PLoadModule() error loading '" .. fileName .. "': " .. err) 96 | end 97 | end 98 | 99 | local debug = require "debug" 100 | function PCall(func, ...) 101 | local ret = { xpcall(func, debug.traceback, ...) } 102 | if ret[1] then 103 | table.remove(ret, 1) 104 | return nil, unpack(ret) 105 | else 106 | return ret[2] 107 | end 108 | end 109 | 110 | function ConPrintf(fmt, ...) 111 | -- Optional 112 | print(string.format(fmt, ...)) 113 | end 114 | function ConPrintTable(tbl, noRecurse) 115 | end 116 | function ConExecute(cmd) 117 | end 118 | function ConClear() 119 | end 120 | function SpawnProcess(cmdName, args) 121 | end 122 | function SetProfiling(isEnabled) 123 | end 124 | function Restart() 125 | end 126 | function Exit() 127 | end 128 | 129 | dofile("Launch.lua") 130 | 131 | -- 132 | -- pob-web related custom code 133 | -- 134 | local mainObject = GetMainObject() 135 | 136 | -- Disable the check for updates because we can't update the app 137 | mainObject["CheckForUpdate"] = function(this) 138 | end 139 | 140 | -- Install the error handler 141 | local showErrMsg = mainObject["ShowErrMsg"] 142 | mainObject["ShowErrMsg"] = function(self, msg, ...) 143 | OnError(string.format(msg, ...)) 144 | showErrMsg(self, msg, ...) 145 | end 146 | 147 | -- Hide the check for updates button 148 | local onInit = mainObject["OnInit"] 149 | mainObject["OnInit"] = function(self) 150 | onInit(self) 151 | self.main.controls.checkUpdate.shown = function() 152 | return false 153 | end 154 | end 155 | 156 | local function runCallback(name, ...) 157 | local callback = GetCallback(name) 158 | return callback(...) 159 | end 160 | 161 | function loadBuildFromCode(code) 162 | mainObject.main:SetMode("BUILD", false, "") 163 | local importTab = mainObject.main.modes["BUILD"].importTab 164 | importTab.controls.importCodeIn:SetText(code, true) 165 | importTab.controls.importCodeMode.selIndex = 2 166 | importTab.controls.importCodeGo.onClick() 167 | end 168 | -------------------------------------------------------------------------------- /packages/packer/src/pack.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs"; 2 | import * as path from "node:path"; 3 | import * as zstd from "@bokuweb/zstd-wasm"; 4 | import AdmZip from "adm-zip"; 5 | import { parseDDSDX10 } from "dds/src"; 6 | import imageSize from "image-size"; 7 | import { gameData, isGame } from "pob-game/src"; 8 | import { default as shelljs } from "shelljs"; 9 | 10 | await zstd.init(); 11 | 12 | shelljs.config.verbose = true; 13 | 14 | const clone = process.argv[4] === "clone"; 15 | 16 | const tag = process.argv[2]; 17 | if (!tag) { 18 | console.error("Invalid tag"); 19 | process.exit(1); 20 | } 21 | 22 | const game = process.argv[3]; 23 | if (!game || !isGame(game)) { 24 | console.error("Invalid game"); 25 | process.exit(1); 26 | } 27 | const def = gameData[game]; 28 | 29 | const buildDir = `build/${game}/${tag}`; 30 | shelljs.mkdir("-p", buildDir); 31 | 32 | // Mirror of the R2 directory structure 33 | const r2Dir = `r2/games/${game}/versions/${tag}`; 34 | shelljs.mkdir("-p", r2Dir); 35 | 36 | const remote = `https://github.com/${def.repository.owner}/${def.repository.name}.git`; 37 | const repoDir = `${buildDir}/repo`; 38 | 39 | if (clone) { 40 | shelljs.rm("-rf", buildDir); 41 | shelljs.exec(`git clone --depth 1 --branch=${tag} ${remote} ${repoDir}`, { fatal: true }); 42 | } 43 | 44 | const outputFile = []; 45 | 46 | const zip = new AdmZip(); 47 | 48 | const basePath = `${repoDir}/src`; 49 | for (const file of shelljs.find(basePath)) { 50 | const relPath = path.relative(basePath, file).replace(/\\/g, "/"); 51 | 52 | if (relPath.startsWith("Export")) continue; 53 | if (fs.statSync(file).isDirectory()) { 54 | if (relPath.length > 0) { 55 | zip.addFile(`${relPath}/`, null as unknown as Buffer); 56 | } 57 | continue; 58 | } 59 | 60 | const isImage = path.extname(file) === ".png" || path.extname(file) === ".jpg"; 61 | const isDDS = file.endsWith(".dds.zst"); 62 | if (isImage || isDDS) { 63 | const { width, height } = isDDS ? ddsSize(file) : imageSize(file); 64 | outputFile.push(`${relPath}\t${width}\t${height}`); 65 | 66 | // PoB runs existence checks against the image file, but actual reading is done in the browser so we include an empty file in the zip 67 | zip.addFile(relPath, Buffer.of()); 68 | 69 | const dest = `${r2Dir}/root/${relPath}`; 70 | shelljs.mkdir("-p", path.dirname(dest)); 71 | shelljs.cp(file, dest); 72 | } 73 | 74 | if ( 75 | path.extname(file) === ".lua" || 76 | path.extname(file) === ".zip" || 77 | path.extname(file).startsWith(".part") || 78 | path.extname(file).startsWith(".json") 79 | ) { 80 | const content = fs.readFileSync(file); 81 | 82 | // patching 83 | const newRelPath = relPath.replace(/Specific_Skill_Stat_Descriptions/g, "specific_skill_stat_descriptions"); 84 | const newContent = (() => { 85 | if (relPath.endsWith("StatDescriber.lua")) { 86 | return Buffer.from( 87 | content.toString().replace(/Specific_Skill_Stat_Descriptions/g, "specific_skill_stat_descriptions"), 88 | ); 89 | } else { 90 | return content; 91 | } 92 | })(); 93 | 94 | zip.addFile(newRelPath, newContent); 95 | } 96 | } 97 | 98 | const basePath2 = `${repoDir}/runtime/lua`; 99 | for (const file of shelljs.find(basePath2)) { 100 | const relPath = path.relative(basePath2, file).replace(/\\/g, "/"); 101 | if (path.extname(file) === ".lua") { 102 | zip.addFile(`lua/${relPath}`, fs.readFileSync(file)); 103 | } 104 | } 105 | 106 | zip.addFile(".image.tsv", Buffer.from(outputFile.join("\n"))); 107 | 108 | const manifest = shelljs.sed( 109 | //, 110 | ``, 111 | `${repoDir}/manifest.xml`, 112 | ); 113 | zip.addFile("installed.cfg", Buffer.from("")); 114 | zip.addFile("manifest.xml", Buffer.from(manifest)); 115 | zip.addFile("changelog.txt", fs.readFileSync(`${repoDir}/changelog.txt`)); 116 | zip.addFile("help.txt", fs.readFileSync(`${repoDir}/help.txt`)); 117 | zip.addFile("LICENSE.md", fs.readFileSync(`${repoDir}/LICENSE.md`)); 118 | 119 | zip.writeZip(`${buildDir}/root.zip`); 120 | shelljs.cp(`${buildDir}/root.zip`, `${r2Dir}/root.zip`); 121 | 122 | // For development, put the root.zip (and its extracted contents) where it is expected 123 | const rootDir = `${buildDir}/root-zipfs`; 124 | shelljs.rm("-rf", rootDir); 125 | shelljs.mkdir("-p", rootDir); 126 | zip.extractAllTo(rootDir, true); 127 | 128 | function ddsSize(file: string) { 129 | const data = zstd.decompress(fs.readFileSync(file)); 130 | const tex = parseDDSDX10(data); 131 | return { 132 | width: tex.extent[0], 133 | height: tex.extent[1], 134 | }; 135 | } 136 | -------------------------------------------------------------------------------- /packages/driver/src/js/sub.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import * as Comlink from "comlink"; 4 | import { log, tag } from "./logger"; 5 | 6 | interface DriverModule extends EmscriptenModule { 7 | cwrap: typeof cwrap; 8 | bridge: unknown; 9 | } 10 | 11 | type Imports = { 12 | subStart: (script: string, funcs: string, subs: string, size: number, data: number) => void; 13 | }; 14 | 15 | export class SubScriptWorker { 16 | private onFinished: (data: Uint8Array) => void = () => {}; 17 | private onError: (message: string) => void = () => {}; 18 | private onFetch: ( 19 | url: string, 20 | header: Record, 21 | body: string | undefined, 22 | ) => Promise<{ 23 | body: string | undefined; 24 | headers: Record; 25 | status: number | undefined; 26 | error: string | undefined; 27 | }> = async () => ({ body: undefined, headers: {}, status: undefined, error: undefined }); 28 | 29 | async start( 30 | script: string, 31 | data: Uint8Array, 32 | onFinished: (data: Uint8Array) => void, 33 | onError: (message: string) => void, 34 | onFetch: ( 35 | url: string, 36 | header: Record, 37 | body: string | undefined, 38 | ) => Promise<{ 39 | body: string | undefined; 40 | headers: Record; 41 | status: number | undefined; 42 | error: string | undefined; 43 | }>, 44 | ) { 45 | const build = "release"; // TODO: configurable 46 | this.onFinished = onFinished; 47 | this.onError = onError; 48 | this.onFetch = onFetch; 49 | log.debug(tag.subscript, "start", { script }); 50 | 51 | const driver = (await import(`../../dist/${build}/driver.mjs`)) as { 52 | default: EmscriptenModuleFactory; 53 | }; 54 | const module = await driver.default({ 55 | print: console.log, // TODO: log.info 56 | printErr: console.warn, // TODO: log.info 57 | }); 58 | 59 | module.bridge = this.resolveExports(module); 60 | const imports = this.resolveImports(module); 61 | 62 | const wasmData = module._malloc(data.length); 63 | module.HEAPU8.set(data, wasmData); 64 | 65 | try { 66 | const ret = await imports.subStart(script, "", "", data.length, wasmData); 67 | log.info(tag.subscript, `finished: ret=${ret}`); 68 | } finally { 69 | module._free(wasmData); 70 | } 71 | } 72 | 73 | private resolveImports(module: DriverModule): Imports { 74 | return { 75 | subStart: module.cwrap("sub_start", "number", ["string", "string", "string", "number", "number"], { 76 | async: true, 77 | }), 78 | }; 79 | } 80 | 81 | private resolveExports(module: DriverModule) { 82 | return { 83 | onSubScriptError: (message: string) => { 84 | log.error(tag.subscript, "onSubScriptError", { message }); 85 | this.onError(message); 86 | }, 87 | onSubScriptFinished: (data: number, size: number) => { 88 | const result = module.HEAPU8.slice(data, data + size); 89 | log.debug(tag.subscript, "onSubScriptFinished", { result }); 90 | this.onFinished(result); 91 | }, 92 | fetch: async (url: string, header: string | undefined, body: string | undefined) => { 93 | if (header?.includes("POESESSID")) { 94 | return JSON.stringify({ error: "POESESSID is not allowed to be sent to the server" }); 95 | } 96 | try { 97 | log.debug(tag.subscript, "fetch request", { url, header, body }); 98 | const headers: Record = header 99 | ? header 100 | .split("\n") 101 | .map(_ => _.split(":")) 102 | .filter(_ => _.length === 2) 103 | .reduce((acc, [k, v]) => Object.assign(acc, { [k.trim()]: v.trim() }), {}) 104 | : {}; 105 | if (!headers["Content-Type"]) { 106 | headers["Content-Type"] = "application/x-www-form-urlencoded"; 107 | } 108 | 109 | const r = await this.onFetch(url, headers, body); 110 | log.debug(tag.subscript, "fetch", r.body, r.status, r.error); 111 | 112 | const headerText = Object.entries(r?.headers ?? {}) 113 | .map(([k, v]) => `${k}: ${v}`) 114 | .join("\n"); 115 | return JSON.stringify({ 116 | body: r?.body, 117 | status: r?.status, 118 | header: headerText, 119 | error: r?.error, 120 | }); 121 | } catch (e) { 122 | log.error(tag.subscript, "fetch error", { error: e }); 123 | return JSON.stringify({ error: (e as Error).message }); 124 | } 125 | }, 126 | }; 127 | } 128 | } 129 | 130 | const worker = new SubScriptWorker(); 131 | Comlink.expose(worker); 132 | -------------------------------------------------------------------------------- /packages/driver/src/c/image.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include "image.h" 6 | #include "util.h" 7 | 8 | enum TextureFlags { 9 | TF_CLAMP = 0x01, 10 | TF_NOMIPMAP = 0x02, 11 | TF_NEAREST = 0x04, 12 | }; 13 | 14 | // ---- VFS 15 | 16 | typedef struct { 17 | char name[1024]; 18 | int width; 19 | int height; 20 | } VfsEntry; 21 | 22 | static VfsEntry st_vfs_entries[1024]; 23 | static int st_vfs_count = 0; 24 | 25 | static void parse_vfs_tsv() { 26 | FILE *f = fopen(".image.tsv", "r"); 27 | if (f == NULL) { 28 | log_error("Failed to open .image.tsv"); 29 | return; 30 | } 31 | 32 | char line[1024]; 33 | while (fgets(line, sizeof(line), f) != NULL) { 34 | char name[1024]; 35 | int width, height; 36 | if (strlen(line) < 1024) { 37 | sscanf(line, "%s\t%d\t%d", name, &width, &height); 38 | if (st_vfs_count < 1024) { 39 | strcpy(st_vfs_entries[st_vfs_count].name, name); 40 | st_vfs_entries[st_vfs_count].width = width; 41 | st_vfs_entries[st_vfs_count].height = height; 42 | } 43 | } 44 | st_vfs_count += 1; 45 | } 46 | 47 | fclose(f); 48 | } 49 | 50 | static VfsEntry *lookup_vfs_entry(const char *name) { 51 | for (int i = 0; i < st_vfs_count; i++) { 52 | if (strcmp(st_vfs_entries[i].name, name) == 0) { 53 | return &st_vfs_entries[i]; 54 | } 55 | } 56 | return NULL; 57 | } 58 | 59 | // ---- 60 | 61 | static const char *IMAGE_HANDLE_TYPE = "ImageHandle"; 62 | 63 | static int st_next_handle = 0; 64 | 65 | static int is_user_data(lua_State *L, int index, const char *type) { 66 | if (lua_type(L, index) != LUA_TUSERDATA) { 67 | return 0; 68 | } 69 | 70 | if (lua_getmetatable(L, index) == 0) { 71 | return 0; 72 | } 73 | 74 | lua_getfield(L, LUA_REGISTRYINDEX, type); 75 | int result = lua_rawequal(L, -2, -1); 76 | lua_pop(L, 2); 77 | 78 | return result; 79 | } 80 | 81 | static ImageHandle *get_image_handle(lua_State *L) { 82 | assert(is_user_data(L, 1, IMAGE_HANDLE_TYPE)); 83 | ImageHandle *image_handle = lua_touserdata(L, 1); 84 | lua_remove(L, 1); 85 | return image_handle; 86 | } 87 | 88 | static int NewImageHandle(lua_State *L) { 89 | ImageHandle *image_handle = lua_newuserdata(L, sizeof(ImageHandle)); 90 | image_handle->handle = ++st_next_handle; 91 | image_handle->width = 1; 92 | image_handle->height = 1; 93 | 94 | lua_pushvalue(L, lua_upvalueindex(1)); 95 | lua_setmetatable(L, -2); 96 | 97 | return 1; 98 | } 99 | 100 | static int ImageHandle_Load(lua_State *L) { 101 | ImageHandle *image_handle = get_image_handle(L); 102 | 103 | int n = lua_gettop(L); 104 | assert(n >= 1); 105 | assert(lua_isstring(L, 1)); 106 | 107 | const char *filename = lua_tostring(L, 1); 108 | 109 | VfsEntry *entry = lookup_vfs_entry(filename); 110 | if (entry != NULL) { 111 | image_handle->width = entry->width; 112 | image_handle->height = entry->height; 113 | } 114 | 115 | int flags = TF_NOMIPMAP; 116 | for (int f = 2; f <= n; ++f) { 117 | if (!lua_isstring(L, f)) { 118 | continue; 119 | } 120 | 121 | const char *flag = lua_tostring(L, f); 122 | if (!strcmp(flag, "ASYNC")) { 123 | // async texture loading removed 124 | } else if (!strcmp(flag, "CLAMP")) { 125 | flags |= TF_CLAMP; 126 | } else if (!strcmp(flag, "MIPMAP")) { 127 | flags &= ~TF_NOMIPMAP; 128 | } else if (!strcmp(flag, "NEAREST")) { 129 | flags |= TF_NEAREST; 130 | } else { 131 | assert(0); 132 | } 133 | } 134 | 135 | EM_ASM({ 136 | Module.imageLoad($0, UTF8ToString($1), $2); 137 | }, image_handle->handle, filename, flags); 138 | 139 | return 0; 140 | } 141 | 142 | static int ImageHandle_ImageSize(lua_State *L) { 143 | ImageHandle *image_handle = get_image_handle(L); 144 | 145 | lua_pushinteger(L, image_handle->width); 146 | lua_pushinteger(L, image_handle->height); 147 | 148 | return 2; 149 | } 150 | 151 | void image_init(lua_State *L) { 152 | // Parse vfs.tsv 153 | parse_vfs_tsv(); 154 | 155 | // Image handles 156 | lua_newtable(L); 157 | lua_pushvalue(L, -1); 158 | lua_pushcclosure(L, NewImageHandle, 1); 159 | lua_setglobal(L, "NewImageHandle"); 160 | 161 | lua_pushvalue(L, -1); 162 | lua_setfield(L, -2, "__index"); 163 | 164 | lua_pushcfunction(L, ImageHandle_Load); 165 | lua_setfield(L, -2, "Load"); 166 | 167 | lua_pushcfunction(L, ImageHandle_ImageSize); 168 | lua_setfield(L, -2, "ImageSize"); 169 | 170 | lua_setfield(L, LUA_REGISTRYINDEX, IMAGE_HANDLE_TYPE); 171 | } 172 | -------------------------------------------------------------------------------- /packages/driver/src/js/image.ts: -------------------------------------------------------------------------------- 1 | import * as zstd from "@bokuweb/zstd-wasm"; 2 | import { Format, Target, Texture, parseDDSDX10 } from "dds/src"; 3 | import { log, tag } from "./logger"; 4 | 5 | export type TextureSource = { 6 | flags: number; 7 | target: Target; 8 | format: Format; 9 | width: number; 10 | height: number; 11 | layers: number; 12 | levels: number; 13 | } & ( 14 | | { 15 | type: "Image"; 16 | texture: (ImageBitmap | OffscreenCanvas | ImageData)[]; 17 | } 18 | | { 19 | type: "Texture"; 20 | texture: Texture; 21 | } 22 | ); 23 | 24 | export namespace TextureSource { 25 | export function newImage(texture: ImageBitmap | OffscreenCanvas | ImageData, flags: number): TextureSource { 26 | return { 27 | flags, 28 | target: Target.TARGET_2D_ARRAY, 29 | format: Format.RGBA8_UNORM_PACK8, 30 | width: texture.width, 31 | height: texture.height, 32 | layers: 1, 33 | levels: 1, 34 | type: "Image", 35 | texture: [texture], 36 | }; 37 | } 38 | 39 | export function newTexture(texture: Texture, flags: number): TextureSource { 40 | return { 41 | flags, 42 | target: texture.target, 43 | format: texture.format, 44 | width: texture.extent[0], 45 | height: texture.extent[1], 46 | layers: texture.layers, 47 | levels: texture.levels, 48 | type: "Texture", 49 | texture, 50 | }; 51 | } 52 | } 53 | 54 | type TextureHolder = { 55 | flags: number; 56 | textureSource: TextureSource | undefined; 57 | }; 58 | 59 | export enum TextureFlags { 60 | TF_CLAMP = 1, 61 | TF_NOMIPMAP = 2, 62 | TF_NEAREST = 4, 63 | } 64 | 65 | let zstdInitialized = false; 66 | 67 | export class ImageRepository { 68 | private readonly prefix: string; 69 | private images: Map = new Map(); 70 | 71 | constructor(prefix: string) { 72 | this.prefix = prefix; 73 | } 74 | 75 | async load(handle: number, src: string, flags: number): Promise { 76 | if (this.images.has(handle)) return; 77 | 78 | const type = src.endsWith(".dds.zst") ? "Texture" : "Image"; 79 | const holder: TextureHolder = { 80 | flags, 81 | textureSource: undefined, 82 | }; 83 | this.images.set(handle, holder); 84 | 85 | const r = await fetch(this.prefix + src, { referrerPolicy: "no-referrer" }); 86 | if (r.ok) { 87 | const blob = await r.blob(); 88 | if (type === "Texture") { 89 | if (!zstdInitialized) { 90 | await zstd.init(); 91 | zstdInitialized = true; 92 | } 93 | const data = zstd.decompress(new Uint8Array(await blob.arrayBuffer())); 94 | try { 95 | const texture0 = parseDDSDX10(data); 96 | const texture = new Texture( 97 | Target.TARGET_2D_ARRAY, 98 | texture0.format, 99 | texture0.extent, 100 | texture0.layers, 101 | texture0.faces, 102 | texture0.levels, 103 | ); 104 | texture.data = texture0.data; 105 | holder.textureSource = TextureSource.newTexture(texture, flags); 106 | } catch (e) { 107 | log.warn(tag.texture, `Failed to load DDS: src=${src}`, e); 108 | } 109 | } else { 110 | const image = await createImageBitmap(blob); 111 | if (flags & TextureFlags.TF_NOMIPMAP) { 112 | holder.textureSource = TextureSource.newImage(image, flags); 113 | } else { 114 | const { levels, mipmaps } = generateMipMap(image); 115 | holder.textureSource = { 116 | flags, 117 | target: Target.TARGET_2D_ARRAY, 118 | format: Format.RGBA8_UNORM_PACK8, 119 | width: image.width, 120 | height: image.height, 121 | layers: 1, 122 | levels, 123 | type: "Image", 124 | texture: mipmaps, 125 | }; 126 | } 127 | } 128 | } 129 | } 130 | 131 | get(handle: number): TextureSource | undefined { 132 | return this.images.get(handle)?.textureSource; 133 | } 134 | } 135 | 136 | function generateMipMap(image: ImageBitmap) { 137 | const levels = Math.floor(Math.log2(Math.max(image.width, image.height))) + 1; 138 | 139 | const canvas = new OffscreenCanvas(image.width, image.height); 140 | const context = canvas.getContext("2d", { willReadFrequently: true }); 141 | if (!context) throw new Error("Failed to get 2D context"); 142 | 143 | let width = image.width; 144 | let height = image.height; 145 | const mipmaps: ImageData[] = []; 146 | 147 | for (let i = 0; i < levels; i++) { 148 | context.clearRect(0, 0, width, height); 149 | context.drawImage(image, 0, 0, image.width, image.height, 0, 0, width, height); 150 | const next = context.getImageData(0, 0, width, height); 151 | mipmaps.push(next); 152 | width = Math.max(1, width >> 1); 153 | height = Math.max(1, height >> 1); 154 | } 155 | 156 | return { 157 | levels, 158 | mipmaps, 159 | }; 160 | } 161 | -------------------------------------------------------------------------------- /packages/driver/src/c/wasmfs/file_table.h: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Emscripten Authors. All rights reserved. 2 | // Emscripten is available under two separate licenses, the MIT license and the 3 | // University of Illinois/NCSA Open Source License. Both these licenses can be 4 | // found in the LICENSE file. 5 | // This file defines the open file table of the new file system. 6 | // Current Status: Work in Progress. 7 | // See https://github.com/emscripten-core/emscripten/issues/15041. 8 | 9 | #pragma once 10 | 11 | #include "file.h" 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | 19 | namespace wasmfs { 20 | static_assert(std::is_same::value, 21 | "size_t should be the same as __wasi_size_t"); 22 | static_assert(std::is_same::value, 23 | "off_t should be the same as __wasi_filedelta_t"); 24 | 25 | // Overflow and underflow behaviour are only defined for unsigned types. 26 | template bool addWillOverFlow(T a, T b) { 27 | if (a > 0 && b > std::numeric_limits::max() - a) { 28 | return true; 29 | } 30 | return false; 31 | } 32 | 33 | class FileTable; 34 | 35 | class OpenFileState : public std::enable_shared_from_this { 36 | std::shared_ptr file; 37 | off_t position = 0; 38 | oflags_t flags; // RD_ONLY, WR_ONLY, RDWR 39 | 40 | // An OpenFileState needs a mutex if there are concurrent accesses on one open 41 | // file descriptor. This could occur if there are multiple seeks on the same 42 | // open file descriptor. 43 | std::recursive_mutex mutex; 44 | 45 | // The number of times this OpenFileState appears in the table. Use this 46 | // instead of shared_ptr::use_count to avoid accidentally counting temporary 47 | // objects. 48 | int uses = 0; 49 | 50 | // We can't make the constructor private because std::make_shared needs to be 51 | // able to call it, but we can make it unusable publicly. 52 | struct private_key { 53 | explicit private_key(int) {} 54 | }; 55 | 56 | // `uses` is protected by the FileTable lock and can be accessed directly by 57 | // `FileTable::Handle. 58 | friend FileTable; 59 | 60 | public: 61 | // Cache directory entries at the moment the directory is opened so that 62 | // subsequent getdents calls have a stable view of the contents. Including 63 | // files removed after the open and excluding files added after the open is 64 | // allowed, and trying to recalculate the directory contents on each getdents 65 | // call could lead to missed directory entries if there are concurrent 66 | // deletions that effectively move entries back past the current read position 67 | // in the open directory. 68 | const std::vector dirents; 69 | 70 | OpenFileState(private_key, 71 | oflags_t flags, 72 | std::shared_ptr file, 73 | std::vector&& dirents) 74 | : file(file), flags(flags), dirents(std::move(dirents)) {} 75 | 76 | [[nodiscard]] static int create(std::shared_ptr file, 77 | oflags_t flags, 78 | std::shared_ptr& out); 79 | 80 | class Handle { 81 | std::shared_ptr openFileState; 82 | std::unique_lock lock; 83 | 84 | public: 85 | Handle(std::shared_ptr openFileState) 86 | : openFileState(openFileState), lock(openFileState->mutex) {} 87 | 88 | std::shared_ptr& getFile() { return openFileState->file; }; 89 | 90 | off_t getPosition() const { return openFileState->position; }; 91 | void setPosition(off_t pos) { openFileState->position = pos; }; 92 | 93 | oflags_t getFlags() const { return openFileState->flags; }; 94 | void setFlags(oflags_t flags) { openFileState->flags = flags; }; 95 | }; 96 | 97 | Handle locked() { return Handle(shared_from_this()); } 98 | }; 99 | 100 | class FileTable { 101 | // Allow WasmFS to construct the FileTable singleton. 102 | friend class WasmFS; 103 | 104 | std::vector> entries; 105 | std::recursive_mutex mutex; 106 | 107 | FileTable(); 108 | 109 | public: 110 | // Access to the FileTable must go through a Handle, which holds its lock. 111 | class Handle { 112 | FileTable& fileTable; 113 | std::unique_lock lock; 114 | 115 | public: 116 | Handle(FileTable& fileTable) 117 | : fileTable(fileTable), lock(fileTable.mutex) {} 118 | 119 | std::shared_ptr getEntry(__wasi_fd_t fd); 120 | 121 | // Set the table slot at `fd` to the given file. If this overwrites the last 122 | // reference to an OpenFileState for a data file in the table, return the 123 | // file so it can be closed by the caller. Do not close the file directly in 124 | // this method so it can be closed later while the FileTable lock is not 125 | // held. 126 | [[nodiscard]] std::shared_ptr 127 | setEntry(__wasi_fd_t fd, std::shared_ptr openFile); 128 | __wasi_fd_t addEntry(std::shared_ptr openFileState); 129 | }; 130 | 131 | Handle locked() { return Handle(*this); } 132 | }; 133 | 134 | } // namespace wasmfs 135 | -------------------------------------------------------------------------------- /packages/driver/src/js/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ray Martone 3 | * @copyright Copyright (c) 2019-2022 Ray Martone 4 | * @license MIT 5 | * @description log adapter that provides level based filtering and tagging 6 | */ 7 | 8 | /** 9 | * Useful for implementing a log event hadnelr 10 | */ 11 | export enum LogLevel { 12 | DEBUG = "DEBUG", 13 | TRACE = "TRACE", 14 | INFO = "INFO", 15 | WARN = "WARN", 16 | ERROR = "ERROR", 17 | OFF = "OFF", 18 | } 19 | 20 | /** 21 | * union 22 | */ 23 | export type LogLevelStr = "DEBUG" | "TRACE" | "INFO" | "WARN" | "ERROR" | "OFF"; 24 | 25 | /** 26 | * Level where `ERROR > WARN > INFO`. 27 | */ 28 | enum Level { 29 | DEBUG = 1, 30 | TRACE = 2, 31 | INFO = 3, 32 | WARN = 4, 33 | ERROR = 5, 34 | OFF = 6, 35 | } 36 | 37 | export type LogCallback = (level: LogLevelStr, tag: string, message: unknown, optionalParams: unknown[]) => void; 38 | 39 | export const tag: Record = {}; 40 | 41 | export class Log { 42 | /** 43 | * init assigns tags a level or they default to INFO 44 | * _tagToLevel hash that maps tags to their level 45 | */ 46 | protected readonly _tagToLevel: Record = {}; 47 | 48 | /** 49 | * callback that supports logging whatever way works best for you! 50 | */ 51 | protected _callback?: LogCallback; 52 | 53 | /** 54 | * init 55 | * @param config? JSON that assigns tags levels. If uninitialized, 56 | * a tag's level defaults to INFO where ERROR > WARN > INFO. 57 | * @param callback? supports logging whatever way works best for you 58 | * - style terminal output with chalk 59 | * - send JSON to a cloud logging service like Splunk 60 | * - log strings and objects to the browser console 61 | * - combine any of the above based on your app's env 62 | * @return {this} supports chaining 63 | */ 64 | init(config?: Record, callback?: LogCallback): this { 65 | for (const k in config) { 66 | this._tagToLevel[k] = Level[config[k] as LogLevelStr] || 1; 67 | } 68 | 69 | if (callback !== undefined) { 70 | this._callback = callback; 71 | } 72 | 73 | for (const key in this._tagToLevel) { 74 | tag[key] = key; 75 | } 76 | return this; 77 | } 78 | 79 | /** 80 | * Writes an error to the log 81 | * @param tag string categorizes a message 82 | * @param message object to log 83 | * @param optionalParams optional list of objects to log 84 | */ 85 | error(tag: T, message: unknown, ...optionalParams: unknown[]): void { 86 | this.log(Level.ERROR, tag, message, optionalParams); 87 | } 88 | 89 | /** 90 | * Writes a warning to the log 91 | * @param tag string categorizes a message 92 | * @param message object to log 93 | * @param optionalParams optional list of objects to log 94 | */ 95 | warn(tag: T, message: unknown, ...optionalParams: unknown[]): void { 96 | this.log(Level.WARN, tag, message, optionalParams); 97 | } 98 | 99 | /** 100 | * Writes info to the log 101 | * @param tag string categorizes a message 102 | * @param message object to log 103 | * @param optionalParams optional list of objects to log 104 | */ 105 | info(tag: T, message: unknown, ...optionalParams: unknown[]): void { 106 | this.log(Level.INFO, tag, message, optionalParams); 107 | } 108 | 109 | /** 110 | * Writes trace to the log 111 | * @param tag string categorizes a message 112 | * @param message object to log 113 | * @param optionalParams optional list of objects to log 114 | */ 115 | trace(tag: T, message: unknown, ...optionalParams: unknown[]): void { 116 | this.log(Level.TRACE, tag, message, optionalParams); 117 | } 118 | 119 | /** 120 | * Writes debug to the log 121 | * @param tag string categorizes a message 122 | * @param message object to log 123 | * @param optionalParams optional list of objects to log 124 | */ 125 | debug(tag: T, message: unknown, ...optionalParams: unknown[]): void { 126 | this.log(Level.DEBUG, tag, message, optionalParams); 127 | } 128 | 129 | private log(level: Level, tag: T, message: unknown, optionalParams: unknown[]): void { 130 | if (this._callback && level >= (this._tagToLevel[tag] ?? Level.DEBUG)) { 131 | this._callback(Level[level], tag, message, optionalParams); 132 | } 133 | } 134 | } 135 | 136 | /** singleton Log instance */ 137 | const logger = { 138 | [LogLevel.ERROR]: (tag, msg, params) => 139 | console.error(`%c${tag}%c`, "background:red;border-radius:5px;padding:0 4px;", "", msg, ...params), 140 | [LogLevel.WARN]: (tag, msg, params) => 141 | console.warn(`%c${tag}%c`, "color:black;background:yellow;border-radius:5px;padding:0 4px;", "", msg, ...params), 142 | [LogLevel.INFO]: (tag, msg, params) => 143 | console.info(`%c${tag}%c`, "background:green;border-radius:5px;padding:0 4px;", "", msg, ...params), 144 | [LogLevel.DEBUG]: (tag, msg, params) => 145 | console.debug(`%c${tag}%c`, "color:black;background:grey;border-radius:5px;padding:0 4px;", "", msg, ...params), 146 | [LogLevel.TRACE]: (tag, msg, params) => 147 | console.trace(`%c${tag}%c`, "color:black;background:cyan;border-radius:5px;padding:0 4px;", "", msg, ...params), 148 | } as Record void>; 149 | 150 | export const log = new Log().init( 151 | { 152 | kvfs: "INFO", 153 | subscript: "INFO", 154 | backend: "DEBUG", 155 | texture: "DEBUG", 156 | }, 157 | (level, tag, msg, params) => { 158 | logger[level as keyof typeof logger](tag, msg, params); 159 | }, 160 | ); 161 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/ZoomControl.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import { MdRefresh } from "react-icons/md"; 3 | 4 | interface ZoomControlProps { 5 | currentZoom: number; 6 | minZoom: number; 7 | maxZoom: number; 8 | onZoomChange: (zoom: number) => void; 9 | onZoomReset: () => void; 10 | onCanvasSizeChange?: (width: number, height: number) => void; 11 | onFixedSizeToggle?: (isFixed: boolean) => void; 12 | currentCanvasSize?: { width: number; height: number }; 13 | isFixedSize?: boolean; 14 | isVisible: boolean; 15 | position: "bottom" | "right" | "left" | "top"; 16 | } 17 | 18 | export const ZoomControl: React.FC = ({ 19 | currentZoom, 20 | minZoom, 21 | maxZoom, 22 | onZoomChange, 23 | onZoomReset, 24 | onCanvasSizeChange, 25 | onFixedSizeToggle, 26 | currentCanvasSize = { width: 1520, height: 800 }, 27 | isFixedSize = false, 28 | isVisible, 29 | position, 30 | }) => { 31 | const [canvasWidth, setCanvasWidth] = useState(currentCanvasSize.width.toString()); 32 | const [canvasHeight, setCanvasHeight] = useState(currentCanvasSize.height.toString()); 33 | 34 | React.useEffect(() => { 35 | setCanvasWidth(currentCanvasSize.width.toString()); 36 | setCanvasHeight(currentCanvasSize.height.toString()); 37 | }, [currentCanvasSize.width, currentCanvasSize.height]); 38 | 39 | const handleSliderChange = useCallback( 40 | (e: React.ChangeEvent) => { 41 | const value = Number.parseFloat(e.target.value); 42 | onZoomChange(value); 43 | }, 44 | [onZoomChange], 45 | ); 46 | 47 | const applyCanvasSize = useCallback( 48 | (width: string, height: string) => { 49 | const w = Number.parseInt(width, 10); 50 | const h = Number.parseInt(height, 10); 51 | 52 | if (w > 0 && h > 0 && onCanvasSizeChange) { 53 | onCanvasSizeChange(w, h); 54 | } 55 | }, 56 | [onCanvasSizeChange], 57 | ); 58 | 59 | const handleWidthChange = useCallback( 60 | (e: React.ChangeEvent) => { 61 | const newWidth = e.target.value; 62 | setCanvasWidth(newWidth); 63 | applyCanvasSize(newWidth, canvasHeight); 64 | }, 65 | [canvasHeight, applyCanvasSize], 66 | ); 67 | 68 | const handleHeightChange = useCallback( 69 | (e: React.ChangeEvent) => { 70 | const newHeight = e.target.value; 71 | setCanvasHeight(newHeight); 72 | applyCanvasSize(canvasWidth, newHeight); 73 | }, 74 | [canvasWidth, applyCanvasSize], 75 | ); 76 | 77 | const handleWidthBlur = useCallback(() => { 78 | applyCanvasSize(canvasWidth, canvasHeight); 79 | }, [canvasWidth, canvasHeight, applyCanvasSize]); 80 | 81 | const handleHeightBlur = useCallback(() => { 82 | applyCanvasSize(canvasWidth, canvasHeight); 83 | }, [canvasWidth, canvasHeight, applyCanvasSize]); 84 | 85 | const zoomPercentage = Math.round(currentZoom * 100); 86 | 87 | if (!isVisible) return null; 88 | 89 | const positionClasses = 90 | position === "bottom" 91 | ? "pw:bottom-16 pw:left-1/2 pw:transform pw:-translate-x-1/2" 92 | : "pw:right-16 pw:top-1/2 pw:transform pw:-translate-y-1/2"; 93 | 94 | return ( 95 |
98 |
99 |
100 | Zoom 101 | 110 | 111 |
112 | 120 |
{zoomPercentage}%
121 |
122 |
123 | 124 |
125 | Size 126 | 138 | × 139 | 151 |
152 | 161 |
162 |
163 |
164 |
165 | ); 166 | }; 167 | -------------------------------------------------------------------------------- /packages/driver/src/c/fs.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #include "fs.h" 11 | 12 | static const char *FS_READDIR_HANDLE_TYPE = "FsReaddirHandle"; 13 | 14 | static int is_user_data(lua_State *L, int index, const char *type) { 15 | if (lua_type(L, index) != LUA_TUSERDATA) { 16 | return 0; 17 | } 18 | 19 | if (lua_getmetatable(L, index) == 0) { 20 | return 0; 21 | } 22 | 23 | lua_getfield(L, LUA_REGISTRYINDEX, type); 24 | int result = lua_rawequal(L, -2, -1); 25 | lua_pop(L, 2); 26 | 27 | return result; 28 | } 29 | 30 | static FsReaddirHandle *get_readdir_handle(lua_State *L, int valid) { 31 | assert(is_user_data(L, 1, FS_READDIR_HANDLE_TYPE)); 32 | FsReaddirHandle *handle = lua_touserdata(L, 1); 33 | lua_remove(L, 1); 34 | if (valid) { 35 | assert(handle->dir != NULL); 36 | } 37 | return handle; 38 | } 39 | 40 | static int NewFileSearch(lua_State *L) { 41 | int n = lua_gettop(L); 42 | assert(n >= 1); 43 | assert(lua_isstring(L, 1)); 44 | 45 | const char *path = lua_tostring(L, 1); 46 | 47 | char _dirname[PATH_MAX]; 48 | strncpy(_dirname, path, sizeof(_dirname) - 1); 49 | dirname(_dirname); 50 | 51 | char *pattern = basename((char *)path); 52 | 53 | DIR *dir = opendir(_dirname); 54 | if (dir == NULL) { 55 | fprintf(stderr, "Failed to open directory: %s\n", _dirname); 56 | return 0; 57 | } 58 | 59 | int dir_only = lua_toboolean(L, 2) != 0; 60 | struct dirent *entry; 61 | while (1) { 62 | entry = readdir(dir); 63 | if (entry == NULL) { 64 | closedir(dir); 65 | return 0; 66 | } 67 | 68 | if ((entry->d_type == DT_DIR) != dir_only || strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { 69 | continue; 70 | } 71 | 72 | if (fnmatch(pattern, entry->d_name, FNM_FILE_NAME) != 0) { 73 | continue; 74 | } 75 | 76 | break; 77 | } 78 | 79 | FsReaddirHandle *handle = lua_newuserdata(L, sizeof(FsReaddirHandle)); 80 | strncpy(handle->path, _dirname, sizeof(handle->path) - 1); 81 | strncpy(handle->pattern, pattern, sizeof(handle->pattern) - 1); 82 | handle->dir = dir; 83 | handle->entry = entry; 84 | handle->dir_only = dir_only; 85 | 86 | lua_pushvalue(L, lua_upvalueindex(1)); 87 | lua_setmetatable(L, -2); 88 | 89 | return 1; 90 | } 91 | 92 | static int FsReaddirHandle_gc(lua_State *L) { 93 | FsReaddirHandle *handle = get_readdir_handle(L, 0); 94 | closedir(handle->dir); 95 | return 0; 96 | } 97 | 98 | static int FsReaddirHandle_NextFile(lua_State *L) { 99 | FsReaddirHandle *handle = get_readdir_handle(L, 1); 100 | 101 | struct dirent *entry; 102 | while (1) { 103 | entry = readdir(handle->dir); 104 | if (entry == NULL) { 105 | closedir(handle->dir); 106 | handle->dir = NULL; 107 | return 0; 108 | } 109 | 110 | if ((entry->d_type == DT_DIR) != handle->dir_only || strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { 111 | continue; 112 | } 113 | 114 | if (fnmatch(handle->pattern, entry->d_name, FNM_FILE_NAME) != 0) { 115 | continue; 116 | } 117 | 118 | break; 119 | } 120 | 121 | handle->entry = entry; 122 | 123 | lua_pushboolean(L, 1); 124 | return 1; 125 | } 126 | 127 | static int FsReaddirHandle_GetFileName(lua_State *L) { 128 | FsReaddirHandle *handle = get_readdir_handle(L, 1); 129 | lua_pushstring(L, handle->entry->d_name); 130 | return 1; 131 | } 132 | 133 | static int FsReaddirHandle_GetFileSize(lua_State *L) { 134 | FsReaddirHandle *handle = get_readdir_handle(L, 1); 135 | lua_pushinteger(L, handle->entry->d_reclen); 136 | return 1; 137 | } 138 | 139 | static int FsReaddirHandle_GetFileModifiedTime(lua_State *L) { 140 | FsReaddirHandle *handle = get_readdir_handle(L, 1); 141 | 142 | char path[PATH_MAX]; 143 | snprintf(path, sizeof(path) - 1, "%s/%s", handle->path, handle->entry->d_name); 144 | 145 | struct stat st; 146 | if (stat(path, &st) == 0) { 147 | lua_pushnumber(L, (double)st.st_mtime); 148 | } else { 149 | lua_pushnumber(L, 0); 150 | } 151 | return 1; 152 | } 153 | 154 | static int MakeDir(lua_State *L) { 155 | int n = lua_gettop(L); 156 | assert(n >= 1); 157 | assert(lua_isstring(L, 1)); 158 | 159 | const char *path = lua_tostring(L, 1); 160 | 161 | int ret = mkdir(path, 0777); 162 | if (ret != 0) { 163 | fprintf(stderr, "Failed to create directory: (%d) %s\n", ret, path); 164 | lua_pushnil(L); 165 | lua_pushstring(L, "Failed to create directory"); 166 | return 2; 167 | } 168 | 169 | lua_pushboolean(L, 1); 170 | return 1; 171 | } 172 | 173 | static int RemoveDir(lua_State *L) { 174 | int n = lua_gettop(L); 175 | assert(n >= 1); 176 | assert(lua_isstring(L, 1)); 177 | 178 | const char *path = lua_tostring(L, 1); 179 | 180 | if (rmdir(path) != 0) { 181 | fprintf(stderr, "Failed to remove directory: %s\n", path); 182 | lua_pushnil(L); 183 | lua_pushstring(L, "Failed to remove directory"); 184 | return 2; 185 | } 186 | 187 | lua_pushboolean(L, 1); 188 | return 1; 189 | } 190 | 191 | void fs_init(lua_State *L) { 192 | lua_newtable(L); 193 | lua_pushvalue(L, -1); 194 | lua_pushcclosure(L, NewFileSearch, 1); 195 | lua_setglobal(L, "NewFileSearch"); 196 | 197 | lua_pushvalue(L, -1); 198 | lua_setfield(L, -2, "__index"); 199 | 200 | lua_pushcfunction(L, FsReaddirHandle_gc); 201 | lua_setfield(L, -2, "__gc"); 202 | 203 | lua_pushcfunction(L, FsReaddirHandle_NextFile); 204 | lua_setfield(L, -2, "NextFile"); 205 | 206 | lua_pushcfunction(L, FsReaddirHandle_GetFileName); 207 | lua_setfield(L, -2, "GetFileName"); 208 | 209 | lua_pushcfunction(L, FsReaddirHandle_GetFileSize); 210 | lua_setfield(L, -2, "GetFileSize"); 211 | 212 | lua_pushcfunction(L, FsReaddirHandle_GetFileModifiedTime); 213 | lua_setfield(L, -2, "GetFileModifiedTime"); 214 | 215 | lua_setfield(L, LUA_REGISTRYINDEX, FS_READDIR_HANDLE_TYPE); 216 | 217 | lua_pushcclosure(L, MakeDir, 0); 218 | lua_setglobal(L, "MakeDir"); 219 | 220 | lua_pushcclosure(L, RemoveDir, 0); 221 | lua_setglobal(L, "RemoveDir"); 222 | } 223 | -------------------------------------------------------------------------------- /packages/web/src/components/PoBWindow.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from "@auth0/auth0-react"; 2 | import { Driver } from "pob-driver/src/js/driver"; 3 | import type { RenderStats } from "pob-driver/src/js/renderer"; 4 | import { type Game, gameData } from "pob-game/src"; 5 | import { useEffect, useRef, useState } from "react"; 6 | import * as use from "react-use"; 7 | import { log, tag } from "../lib/logger"; 8 | import ErrorDialog from "./ErrorDialog"; 9 | 10 | const { useHash } = use; 11 | 12 | export default function PoBWindow(props: { 13 | game: Game; 14 | version: string; 15 | onFrame: (at: number, time: number, stats?: RenderStats) => void; 16 | onTitleChange: (title: string) => void; 17 | onLayerVisibilityCallbackReady?: (callback: (layer: number, sublayer: number, visible: boolean) => void) => void; 18 | toolbarComponent?: React.ComponentType<{ position: "top" | "bottom" | "left" | "right"; isLandscape: boolean }>; 19 | onDriverReady?: (driver: Driver) => void; 20 | }) { 21 | const auth0 = useAuth0(); 22 | 23 | const container = useRef(null); 24 | const driverRef = useRef(null); 25 | const onFrameRef = useRef(props.onFrame); 26 | const onTitleChangeRef = useRef(props.onTitleChange); 27 | const onLayerVisibilityCallbackReadyRef = useRef(props.onLayerVisibilityCallbackReady); 28 | 29 | onFrameRef.current = props.onFrame; 30 | onTitleChangeRef.current = props.onTitleChange; 31 | onLayerVisibilityCallbackReadyRef.current = props.onLayerVisibilityCallbackReady; 32 | 33 | const [token, setToken] = useState(); 34 | useEffect(() => { 35 | async function getToken() { 36 | if (auth0.isAuthenticated) { 37 | const t = await auth0.getAccessTokenSilently(); 38 | setToken(t); 39 | } 40 | } 41 | getToken(); 42 | }, [auth0, auth0.isAuthenticated]); 43 | 44 | const [hash, _setHash] = useHash(); 45 | const [buildCode, setBuildCode] = useState(""); 46 | useEffect(() => { 47 | if (hash.startsWith("#build=")) { 48 | const code = hash.slice("#build=".length); 49 | setBuildCode(code); 50 | } else if (hash.startsWith("#=")) { 51 | const code = hash.slice("#=".length); 52 | setBuildCode(code); 53 | } 54 | }, [hash]); 55 | 56 | const [loading, setLoading] = useState(true); 57 | const [error, setError] = useState(); 58 | const [showErrorDialog, setShowErrorDialog] = useState(true); 59 | 60 | useEffect(() => { 61 | if (driverRef.current && props.toolbarComponent) { 62 | driverRef.current.setExternalToolbarComponent(props.toolbarComponent); 63 | } 64 | }, [props.toolbarComponent]); 65 | 66 | // biome-ignore lint/correctness/useExhaustiveDependencies: toolbarComponent is handled separately 67 | useEffect(() => { 68 | const assetPrefix = `${__ASSET_PREFIX__}/games/${props.game}/versions/${props.version}`; 69 | log.debug(tag.pob, "loading assets from", assetPrefix); 70 | 71 | const _driver = new Driver("release", assetPrefix, { 72 | onError: error => { 73 | setError(error); 74 | setShowErrorDialog(true); 75 | }, 76 | onFrame: (at, time, stats) => onFrameRef.current(at, time, stats), 77 | onFetch: async (url, headers, body) => { 78 | let rep = undefined; 79 | 80 | if (url.startsWith("https://pobb.in/")) { 81 | try { 82 | const r = await fetch(url, { 83 | method: body ? "POST" : "GET", 84 | body, 85 | headers, 86 | }); 87 | if (r.ok) { 88 | rep = { 89 | body: await r.text(), 90 | headers: Object.fromEntries(r.headers.entries()), 91 | status: r.status, 92 | }; 93 | log.debug(tag.pob, "CORS fetch success", url, rep); 94 | } 95 | } catch (e) { 96 | log.warn(tag.pob, "CORS fetch error", e); 97 | } 98 | } 99 | 100 | if (!rep) { 101 | const r = await fetch("/api/fetch", { 102 | method: "POST", 103 | body: JSON.stringify({ url, headers, body }), 104 | }); 105 | rep = await r.json(); 106 | } 107 | 108 | return rep; 109 | }, 110 | onTitleChange: title => onTitleChangeRef.current(title), 111 | }); 112 | 113 | driverRef.current = _driver; 114 | 115 | (async () => { 116 | try { 117 | await _driver.start({ 118 | userDirectory: gameData[props.game].userDirectory, 119 | cloudflareKvPrefix: "/api/kv", 120 | cloudflareKvAccessToken: token, 121 | cloudflareKvUserNamespace: gameData[props.game].cloudflareKvNamespace, 122 | }); 123 | log.debug(tag.pob, "started", container.current); 124 | if (buildCode) { 125 | log.info(tag.pob, "loading build from ", buildCode); 126 | await _driver.loadBuildFromCode(buildCode); 127 | } 128 | if (container.current) _driver.attachToDOM(container.current); 129 | 130 | if (props.toolbarComponent) { 131 | _driver.setExternalToolbarComponent(props.toolbarComponent); 132 | } 133 | 134 | onLayerVisibilityCallbackReadyRef.current?.((layer: number, sublayer: number, visible: boolean) => { 135 | _driver.setLayerVisible(layer, sublayer, visible); 136 | }); 137 | 138 | props.onDriverReady?.(_driver); 139 | 140 | setLoading(false); 141 | } catch (e) { 142 | setError(e); 143 | setShowErrorDialog(true); 144 | setLoading(false); 145 | } 146 | })(); 147 | 148 | return () => { 149 | _driver.detachFromDOM(); 150 | _driver.destory(); 151 | driverRef.current = null; 152 | setLoading(true); 153 | }; 154 | }, [props.game, props.version, token, buildCode]); 155 | 156 | if (error) { 157 | log.error(tag.pob, error); 158 | return ( 159 | <> 160 | {showErrorDialog && ( 161 | window.location.reload()} 164 | onClose={() => setShowErrorDialog(false)} 165 | /> 166 | )} 167 |
173 | 174 | ); 175 | } 176 | 177 | return ( 178 |
184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /packages/web/src/root.tsx: -------------------------------------------------------------------------------- 1 | import "./app.css"; 2 | 3 | import { Auth0Provider } from "@auth0/auth0-react"; 4 | import * as Sentry from "@sentry/react"; 5 | import type React from "react"; 6 | import { useState } from "react"; 7 | 8 | import "./lib/logger"; 9 | import { Link, Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from "react-router"; 10 | import type { Route } from "./+types/root"; 11 | 12 | if (import.meta.env.VITE_SENTRY_DSN) { 13 | Sentry.init({ 14 | dsn: import.meta.env.VITE_SENTRY_DSN, 15 | integrations: [ 16 | Sentry.browserTracingIntegration(), 17 | Sentry.replayIntegration(), 18 | Sentry.consoleLoggingIntegration({ levels: ["log", "warn", "error"] }), 19 | ], 20 | enableLogs: true, 21 | // Performance Monitoring 22 | tracesSampleRate: 1.0, // Capture 100% of the transactions 23 | // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled 24 | tracePropagationTargets: ["localhost", /^https:\/\/yourserver\.io\/api/], 25 | // Session Replay 26 | replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. 27 | replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. 28 | }); 29 | } 30 | 31 | export const links: Route.LinksFunction = () => [ 32 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 33 | { 34 | rel: "preconnect", 35 | href: "https://fonts.gstatic.com", 36 | crossOrigin: "anonymous", 37 | }, 38 | { 39 | rel: "stylesheet", 40 | href: "https://fonts.googleapis.com/css2?family=Poiret+One&display=swap", 41 | }, 42 | ]; 43 | 44 | export function Layout({ children }: { children: React.ReactNode }) { 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | pob.cool 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | {children} 71 | 72 | 73 | 74 | 75 | ); 76 | } 77 | 78 | export function HydrateFallback() {} 79 | 80 | export default function Root() { 81 | return ( 82 | 95 | 96 | 97 | ); 98 | } 99 | 100 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 101 | let message = "Oops!"; 102 | let details = "An unexpected error occurred."; 103 | let stack: string | undefined; 104 | 105 | if (isRouteErrorResponse(error)) { 106 | message = error.status === 404 ? "404" : "Error"; 107 | details = error.status === 404 ? "The requested page could not be found." : error.statusText || details; 108 | } else if (error && error instanceof Error) { 109 | stack = error.stack; 110 | } 111 | 112 | const [copy, setCopy] = useState("copy"); 113 | 114 | return ( 115 |
116 |
117 |

{message}

118 |

{details}

119 | {/*

{details}

*/} 120 | {stack && ( 121 |
122 |
123 |               {stack}
124 |             
125 |
126 | 141 |
142 |
143 | )} 144 |
145 | 146 | Go back home 147 | 148 | 149 | File an issue 150 | 151 |
152 |
153 |
154 | ); 155 | } 156 | -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/OverlayContainer.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | import type { DOMKeyboardState } from "../keyboard"; 5 | import "./overlay.css"; 6 | import type { FrameData, RenderStats } from "./PerformanceOverlay"; 7 | import { PerformanceOverlay } from "./PerformanceOverlay"; 8 | import { Toolbar } from "./Toolbar"; 9 | import { VirtualKeyboard } from "./VirtualKeyboard"; 10 | import type { ToolbarCallbacks, ToolbarPosition } from "./types"; 11 | 12 | interface OverlayContainerProps { 13 | callbacks: ToolbarCallbacks; 14 | keyboardState: DOMKeyboardState; 15 | panModeEnabled?: boolean; 16 | currentZoom?: number; 17 | currentCanvasSize?: { width: number; height: number }; 18 | isFixedSize?: boolean; 19 | frames?: FrameData[]; 20 | renderStats?: RenderStats | null; 21 | performanceVisible?: boolean; 22 | onLayerVisibilityChange?: (layer: number, sublayer: number, visible: boolean) => void; 23 | externalComponent?: React.ComponentType<{ position: ToolbarPosition; isLandscape: boolean }>; 24 | } 25 | 26 | export const OverlayContainer: React.FC = ({ 27 | callbacks, 28 | keyboardState, 29 | panModeEnabled: externalPanMode, 30 | currentZoom = 1.0, 31 | currentCanvasSize = { width: 1520, height: 800 }, 32 | isFixedSize = false, 33 | frames = [], 34 | renderStats = null, 35 | performanceVisible = false, 36 | onLayerVisibilityChange, 37 | externalComponent, 38 | }) => { 39 | const [position, setPosition] = useState("bottom"); 40 | const [isLandscape, setIsLandscape] = useState(false); 41 | const [panModeEnabled, setPanModeEnabled] = useState(externalPanMode ?? false); 42 | const [keyboardVisible, setKeyboardVisible] = useState(false); 43 | const [performanceOverlayVisible, setPerformanceOverlayVisible] = useState(performanceVisible); 44 | 45 | useEffect(() => { 46 | setPerformanceOverlayVisible(performanceVisible); 47 | }, [performanceVisible]); 48 | 49 | useEffect(() => { 50 | if (externalPanMode !== undefined) { 51 | setPanModeEnabled(externalPanMode); 52 | } 53 | }, [externalPanMode]); 54 | 55 | const handlePanModeToggle = useCallback( 56 | (enabled: boolean) => { 57 | setPanModeEnabled(enabled); 58 | callbacks.onPanModeToggle(enabled); 59 | }, 60 | [callbacks], 61 | ); 62 | 63 | const handleKeyboardToggle = useCallback(() => { 64 | setKeyboardVisible(prev => !prev); 65 | }, []); 66 | 67 | const handlePerformanceToggle = useCallback(() => { 68 | setPerformanceOverlayVisible(prev => !prev); 69 | callbacks.onPerformanceToggle(); 70 | }, [callbacks]); 71 | 72 | const stopPropagation = useCallback((e: React.SyntheticEvent) => { 73 | e.stopPropagation(); 74 | }, []); 75 | 76 | const wrappedCallbacks: ToolbarCallbacks = { 77 | ...callbacks, 78 | onPanModeToggle: handlePanModeToggle, 79 | onKeyboardToggle: handleKeyboardToggle, 80 | onPerformanceToggle: handlePerformanceToggle, 81 | }; 82 | 83 | useEffect(() => { 84 | const updateLayout = () => { 85 | const windowWidth = window.innerWidth; 86 | const windowHeight = window.innerHeight; 87 | const isPortrait = windowHeight > windowWidth; 88 | setPosition(isPortrait ? "bottom" : "right"); 89 | setIsLandscape(!isPortrait); 90 | }; 91 | 92 | updateLayout(); 93 | window.addEventListener("resize", updateLayout); 94 | window.addEventListener("orientationchange", updateLayout); 95 | 96 | return () => { 97 | window.removeEventListener("resize", updateLayout); 98 | window.removeEventListener("orientationchange", updateLayout); 99 | }; 100 | }, []); 101 | 102 | return ( 103 |
112 |
133 | 145 |
146 | 147 | 153 |
154 | ); 155 | }; 156 | 157 | export class ReactOverlayManager { 158 | private root: ReturnType | null = null; 159 | private container: HTMLDivElement; 160 | private currentProps: OverlayContainerProps | null = null; 161 | 162 | constructor(container: HTMLDivElement) { 163 | this.container = container; 164 | this.root = createRoot(container); 165 | } 166 | 167 | render(props: OverlayContainerProps) { 168 | this.currentProps = props; 169 | if (this.root) { 170 | this.root.render(); 171 | } 172 | } 173 | 174 | updateState( 175 | updates: Partial< 176 | Pick< 177 | OverlayContainerProps, 178 | | "panModeEnabled" 179 | | "currentZoom" 180 | | "currentCanvasSize" 181 | | "isFixedSize" 182 | | "frames" 183 | | "renderStats" 184 | | "performanceVisible" 185 | | "externalComponent" 186 | > 187 | >, 188 | ) { 189 | if (this.currentProps && this.root) { 190 | const newProps = { ...this.currentProps, ...updates }; 191 | this.currentProps = newProps; 192 | this.root.render(); 193 | } 194 | } 195 | 196 | destroy() { 197 | if (this.root) { 198 | this.root.unmount(); 199 | this.root = null; 200 | } 201 | this.currentProps = null; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /packages/driver/src/js/keyboard.ts: -------------------------------------------------------------------------------- 1 | declare const DOMKeySymbol: unique symbol; 2 | /// A string that represents a key from the DOM KeyboardEvent.key property 3 | export type DOMKey = string & { [DOMKeySymbol]: never }; 4 | 5 | /// A string that represents a key in Path of Building 6 | declare const PoBKeySymbol: unique symbol; 7 | export type PoBKey = string & { [PoBKeySymbol]: never }; 8 | 9 | export type KeyboardStateCallbacks = { 10 | onKeyDown: (state: PoBKeyboardState, key: PoBKey, doubleClick: number) => void; 11 | onKeyUp: (state: PoBKeyboardState, key: PoBKey) => void; 12 | onChar: (state: PoBKeyboardState, char: string) => void; 13 | }; 14 | 15 | // Provides a view of the keyboard state in terms of PoB keys (including mouse buttons) 16 | export type PoBKeyboardState = { 17 | pobKeys: Set; 18 | 19 | keydown: (pobKey: PoBKey, doubleClick: number) => void; 20 | keyup: (pobKey: PoBKey) => void; 21 | keypress: (char: string) => void; 22 | }; 23 | export const PoBKeyboardState = { 24 | make(callbacks: KeyboardStateCallbacks): PoBKeyboardState { 25 | const keys = new Set(); 26 | 27 | return { 28 | pobKeys: keys, 29 | 30 | keydown(pobKey: PoBKey, doubleclick: number): void { 31 | if (doubleclick < 1) { 32 | keys.add(pobKey); 33 | } 34 | callbacks?.onKeyDown(this, pobKey, doubleclick); 35 | }, 36 | 37 | keyup(pobKey: PoBKey): void { 38 | keys.delete(pobKey); 39 | callbacks?.onKeyUp(this, pobKey); 40 | }, 41 | 42 | keypress(char: string): void { 43 | callbacks?.onChar(this, char); 44 | }, 45 | }; 46 | }, 47 | }; 48 | 49 | // Manages the state of the physical and virtual keyboard 50 | export type DOMKeyboardState = { 51 | keydown: (domKey: DOMKey) => void; 52 | keyup: (domKey: DOMKey) => void; 53 | keypress: (char: string) => void; 54 | 55 | virtualKeyPress: (domKey: DOMKey, isModifier: boolean) => Set; 56 | }; 57 | export const DOMKeyboardState = { 58 | make(pobKeyboardState: PoBKeyboardState): DOMKeyboardState { 59 | const heldKeys = new Set(); 60 | 61 | return { 62 | keydown(domKey: DOMKey) { 63 | pobKeyboardState.keydown(domKeyToPobKey(domKey), 0); 64 | 65 | const char = EXTRA_CHAR_MAP.get(domKey); 66 | if (char) { 67 | pobKeyboardState?.keypress(char); 68 | } 69 | }, 70 | 71 | keyup(domKey: DOMKey): void { 72 | pobKeyboardState.keyup(domKeyToPobKey(domKey)); 73 | }, 74 | 75 | keypress(char: string): void { 76 | pobKeyboardState.keypress(char); 77 | }, 78 | 79 | virtualKeyPress(domKey: DOMKey, isModifier: boolean): Set { 80 | if (isModifier) { 81 | if (heldKeys.has(domKey)) { 82 | heldKeys.delete(domKey); 83 | this.keyup(domKey); 84 | } else { 85 | heldKeys.add(domKey); 86 | this.keydown(domKey); 87 | } 88 | } else { 89 | this.keydown(domKey); 90 | if (domKey.length === 1) { 91 | const char = heldKeys.has("Shift" as DOMKey) ? applyShiftTransformation(domKey) : domKey; 92 | this.keypress(char); 93 | } 94 | this.keyup(domKey); 95 | } 96 | return heldKeys; 97 | }, 98 | }; 99 | }, 100 | }; 101 | 102 | const DOM_TO_POB_KEY_MAP: Map = new Map([ 103 | ["Backspace", "BACK"], 104 | ["Tab", "TAB"], 105 | ["Enter", "RETURN"], 106 | ["Escape", "ESCAPE"], 107 | ["Space", " "], 108 | ["Control", "CTRL"], 109 | ["Shift", "SHIFT"], 110 | ["Alt", "ALT"], 111 | ["Pause", "PAUSE"], 112 | ["PageUp", "PAGEUP"], 113 | ["PageDown", "PAGEDOWN"], 114 | ["End", "END"], 115 | ["Home", "HOME"], 116 | ["PrintScreen", "PRINTSCREEN"], 117 | ["Insert", "INSERT"], 118 | ["Delete", "DELETE"], 119 | ["ArrowUp", "UP"], 120 | ["ArrowDown", "DOWN"], 121 | ["ArrowLeft", "LEFT"], 122 | ["ArrowRight", "RIGHT"], 123 | ["F1", "F1"], 124 | ["F2", "F2"], 125 | ["F3", "F3"], 126 | ["F4", "F4"], 127 | ["F5", "F5"], 128 | ["F6", "F6"], 129 | ["F7", "F7"], 130 | ["F8", "F8"], 131 | ["F9", "F9"], 132 | ["F10", "F10"], 133 | ["F11", "F11"], 134 | ["F12", "F12"], 135 | ["F13", "F13"], 136 | ["F14", "F14"], 137 | ["F15", "F15"], 138 | ["NumLock", "NUMLOCK"], 139 | ["ScrollLock", "SCROLLLOCK"], 140 | ["LEFTBUTTON", "LEFTBUTTON"], 141 | ["MIDDLEBUTTON", "MIDDLEBUTTON"], 142 | ["RIGHTBUTTON", "RIGHTBUTTON"], 143 | ["MOUSE4", "MOUSE4"], 144 | ["MOUSE5", "MOUSE5"], 145 | ["WHEELUP", "WHEELUP"], 146 | ["WHEELDOWN", "WHEELDOWN"], 147 | ] as [DOMKey, PoBKey][]); 148 | 149 | const EXTRA_CHAR_MAP: Map = new Map([ 150 | ["Backspace", "\b"], 151 | ["Tab", "\t"], 152 | ["Enter", "\r"], 153 | ["Escape", "\u001B"], 154 | ] as [DOMKey, string][]); 155 | 156 | function domKeyToPobKey(domKey: DOMKey): PoBKey { 157 | if (DOM_TO_POB_KEY_MAP.has(domKey)) { 158 | return DOM_TO_POB_KEY_MAP.get(domKey)!; 159 | } else if (domKey.length === 1) { 160 | return domKey.toLowerCase() as PoBKey; 161 | } else { 162 | return domKey as string as PoBKey; 163 | } 164 | } 165 | 166 | const SHIFT_MAP: Map = new Map([ 167 | ["1", "!"], 168 | ["2", "@"], 169 | ["3", "#"], 170 | ["4", "$"], 171 | ["5", "%"], 172 | ["6", "^"], 173 | ["7", "&"], 174 | ["8", "*"], 175 | ["9", "("], 176 | ["0", ")"], 177 | ["`", "~"], 178 | ["-", "_"], 179 | ["=", "+"], 180 | ["[", "{"], 181 | ["]", "}"], 182 | ["\\", "|"], 183 | [";", ":"], 184 | ["'", '"'], 185 | [",", "<"], 186 | [".", ">"], 187 | ["/", "?"], 188 | ]); 189 | 190 | function applyShiftTransformation(domKey: DOMKey): string { 191 | if (/^[a-z]$/.test(domKey)) { 192 | return domKey.toUpperCase(); 193 | } 194 | const char = SHIFT_MAP.get(domKey); 195 | if (char) { 196 | return char; 197 | } else { 198 | return domKey; 199 | } 200 | } 201 | 202 | // Manages the state of the keyboard, including currently pressed keys and held modifier keys 203 | // Handles DOM keyboard events and forwards them to the KeyboardState 204 | export type KeyboardHandler = { 205 | destroy(): void; 206 | }; 207 | export const KeyboardHandler = { 208 | make(el: HTMLElement, keyboardState: DOMKeyboardState): KeyboardHandler { 209 | const ac = new AbortController(); 210 | const signal = ac.signal; 211 | 212 | el.addEventListener( 213 | "keydown", 214 | e => { 215 | ["Tab", "Escape", "Enter"].includes(e.key) && e.preventDefault(); 216 | keyboardState.keydown(e.key as DOMKey); 217 | }, 218 | { signal }, 219 | ); 220 | 221 | el.addEventListener( 222 | "keyup", 223 | e => { 224 | e.preventDefault(); 225 | keyboardState.keyup(e.key as DOMKey); 226 | }, 227 | { signal }, 228 | ); 229 | 230 | el.addEventListener( 231 | "keypress", 232 | e => { 233 | e.preventDefault(); 234 | keyboardState.keypress(e.key); 235 | }, 236 | { signal }, 237 | ); 238 | 239 | return { 240 | destroy() { 241 | ac.abort(); 242 | }, 243 | }; 244 | }, 245 | }; 246 | -------------------------------------------------------------------------------- /packages/web/src/components/SettingsDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from "@auth0/auth0-react"; 2 | import { ArrowTopRightOnSquareIcon, ChartBarIcon, HomeIcon, UserIcon, XMarkIcon } from "@heroicons/react/24/solid"; 3 | import { forwardRef } from "react"; 4 | 5 | interface SettingsDialogProps { 6 | game: string; 7 | performanceVisible: boolean; 8 | onPerformanceToggle: () => void; 9 | } 10 | 11 | export const SettingsDialog = forwardRef( 12 | ({ performanceVisible, onPerformanceToggle }, ref) => { 13 | const { loginWithRedirect, logout, user, isAuthenticated, isLoading } = useAuth0(); 14 | const closeDialog = () => { 15 | if (ref && typeof ref !== "function" && ref.current) { 16 | ref.current.close(); 17 | } 18 | }; 19 | 20 | return ( 21 | 22 |
23 |
24 |
25 | 26 | pob.cool 27 |
28 | 31 |
32 | 33 |
34 |
35 |

Account

36 | 37 | {isLoading ? ( 38 |
39 |
40 | Loading account... 41 |
42 | ) : isAuthenticated ? ( 43 |
44 |
45 | 46 |
47 |
48 |
Signed in
49 | {user?.name &&
{user.name}
} 50 |
51 | 62 |
63 | ) : ( 64 |
65 |
66 | 67 |
68 |
69 |
Not signed in
70 |
Sync builds across devices
71 |
72 | 83 |
84 | )} 85 |
86 | 87 |
88 |

Navigation

89 | 100 |
101 | 102 |
103 |

Preferences

104 |
105 | 120 |
121 |
122 | 123 |
124 |

125 | Version {APP_VERSION} 126 | 132 | (Changelog 133 | ) 134 | 135 |

136 |
137 | 138 |
139 |

This product isn't affiliated with or endorsed by Grinding Gear Games.

140 |

141 | © 2025 Koji AGAWA ( 142 | 143 | @atty303 144 | 145 | ) 146 |

147 |
148 |
149 |
150 | 151 |
152 | 153 |
154 |
155 | ); 156 | }, 157 | ); 158 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "poe1": { 3 | "head": "v2.59.2", 4 | "versions": [ 5 | { 6 | "value": "v2.59.2", 7 | "date": "2025-11-23T07:39:04Z" 8 | }, 9 | { 10 | "value": "v2.59.1", 11 | "date": "2025-11-22T21:12:12Z" 12 | }, 13 | { 14 | "value": "v2.59.0", 15 | "date": "2025-11-22T15:49:55Z" 16 | }, 17 | { 18 | "value": "v2.58.1", 19 | "date": "2025-11-05T04:35:10Z" 20 | }, 21 | { 22 | "value": "v2.58.0", 23 | "date": "2025-11-03T06:29:43Z" 24 | }, 25 | { 26 | "value": "v2.57.0", 27 | "date": "2025-10-31T01:40:37Z" 28 | }, 29 | { 30 | "value": "v2.56.0", 31 | "date": "2025-08-11T16:16:02Z" 32 | }, 33 | { 34 | "value": "v2.55.5", 35 | "date": "2025-07-18T03:04:15Z" 36 | }, 37 | { 38 | "value": "v2.55.4", 39 | "date": "2025-07-14T07:13:26Z" 40 | }, 41 | { 42 | "value": "v2.55.3", 43 | "date": "2025-07-02T05:37:26Z" 44 | }, 45 | { 46 | "value": "v2.55.2", 47 | "date": "2025-07-01T06:08:03Z" 48 | }, 49 | { 50 | "value": "v2.55.1", 51 | "date": "2025-06-30T07:18:53Z" 52 | }, 53 | { 54 | "value": "v2.55.0", 55 | "date": "2025-06-29T10:55:17Z" 56 | }, 57 | { 58 | "value": "v2.54.0", 59 | "date": "2025-06-14T02:15:19Z" 60 | }, 61 | { 62 | "value": "v2.53.1", 63 | "date": "2025-06-13T17:10:42Z" 64 | }, 65 | { 66 | "value": "v2.53.0", 67 | "date": "2025-06-13T03:26:13Z" 68 | }, 69 | { 70 | "value": "v2.52.3", 71 | "date": "2025-02-20T19:57:28Z" 72 | }, 73 | { 74 | "value": "v2.52.2", 75 | "date": "2025-02-20T06:46:31Z" 76 | }, 77 | { 78 | "value": "v2.52.1", 79 | "date": "2025-02-20T04:49:12Z" 80 | }, 81 | { 82 | "value": "v2.52.0", 83 | "date": "2025-02-20T03:52:32Z" 84 | }, 85 | { 86 | "value": "v2.51.0", 87 | "date": "2025-02-14T05:34:29Z" 88 | }, 89 | { 90 | "value": "v2.50.1", 91 | "date": "2025-02-14T05:34:29Z" 92 | }, 93 | { 94 | "value": "v2.50.0", 95 | "date": "2025-02-12T16:00:54Z" 96 | }, 97 | { 98 | "value": "v2.49.3", 99 | "date": "2024-11-24T05:41:20Z" 100 | }, 101 | { 102 | "value": "v2.49.2", 103 | "date": "2024-11-19T06:02:38Z" 104 | }, 105 | { 106 | "value": "v2.49.1", 107 | "date": "2024-11-18T18:29:49Z" 108 | }, 109 | { 110 | "value": "v2.49.0", 111 | "date": "2024-11-18T11:40:24Z" 112 | }, 113 | { 114 | "value": "v2.48.2", 115 | "date": "2024-08-17T18:40:53Z" 116 | }, 117 | { 118 | "value": "v2.48.1", 119 | "date": "2024-08-14T21:29:04Z" 120 | }, 121 | { 122 | "value": "v2.48.0", 123 | "date": "2024-08-14T19:07:49Z" 124 | }, 125 | { 126 | "value": "v2.47.3", 127 | "date": "2024-07-30T04:57:16Z" 128 | }, 129 | { 130 | "value": "v2.47.2", 131 | "date": "2024-07-29T05:31:08Z" 132 | }, 133 | { 134 | "value": "v2.47.1", 135 | "date": "2024-07-29T04:26:47Z" 136 | }, 137 | { 138 | "value": "v2.47.0", 139 | "date": "2024-07-29T04:26:47Z" 140 | }, 141 | { 142 | "value": "v2.46.0", 143 | "date": "2024-07-29T04:26:47Z" 144 | }, 145 | { 146 | "value": "v2.45.0", 147 | "date": "2024-07-24T05:15:34Z" 148 | }, 149 | { 150 | "value": "v2.44.0", 151 | "date": "2024-07-24T05:15:34Z" 152 | }, 153 | { 154 | "value": "v2.43.0", 155 | "date": "2024-07-22T17:35:20Z" 156 | }, 157 | { 158 | "value": "v2.42.0", 159 | "date": "2024-03-29T23:27:09Z" 160 | }, 161 | { 162 | "value": "v2.41.1", 163 | "date": "2024-03-27T07:10:37Z" 164 | } 165 | ] 166 | }, 167 | "poe2": { 168 | "head": "v0.14.0", 169 | "versions": [ 170 | { 171 | "value": "v0.14.0", 172 | "date": "2025-12-19T03:51:16Z" 173 | }, 174 | { 175 | "value": "v0.13.0", 176 | "date": "2025-12-15T21:36:17Z" 177 | }, 178 | { 179 | "value": "v0.12.2", 180 | "date": "2025-09-16T11:42:05Z" 181 | }, 182 | { 183 | "value": "v0.12.1", 184 | "date": "2025-09-15T09:57:49Z" 185 | }, 186 | { 187 | "value": "v0.12.0", 188 | "date": "2025-09-14T20:41:44Z" 189 | }, 190 | { 191 | "value": "v0.11.2", 192 | "date": "2025-09-02T18:00:01Z" 193 | }, 194 | { 195 | "value": "v0.11.1", 196 | "date": "2025-09-02T16:48:54Z" 197 | }, 198 | { 199 | "value": "v0.10.2", 200 | "date": "2025-08-30T13:42:45Z" 201 | }, 202 | { 203 | "value": "v0.10.1", 204 | "date": "2025-08-30T10:49:36Z" 205 | }, 206 | { 207 | "value": "v0.11.0", 208 | "date": "2025-09-02T15:46:59Z" 209 | }, 210 | { 211 | "value": "v0.10.0", 212 | "date": "2025-08-30T00:39:58Z" 213 | }, 214 | { 215 | "value": "v0.9.0", 216 | "date": "2025-08-23T05:59:34Z" 217 | }, 218 | { 219 | "value": "v0.8.0", 220 | "date": "2025-04-16T15:33:36Z" 221 | }, 222 | { 223 | "value": "v0.7.1", 224 | "date": "2025-04-09T19:43:47Z" 225 | }, 226 | { 227 | "value": "v0.7.0", 228 | "date": "2025-04-09T19:16:50Z" 229 | }, 230 | { 231 | "value": "v0.6.0", 232 | "date": "2025-04-06T18:17:08Z" 233 | }, 234 | { 235 | "value": "v0.5.0", 236 | "date": "2025-02-12T14:07:09Z" 237 | }, 238 | { 239 | "value": "v0.4.1", 240 | "date": "2025-02-04T16:42:25Z" 241 | }, 242 | { 243 | "value": "v0.4.0", 244 | "date": "2025-02-04T07:24:48Z" 245 | }, 246 | { 247 | "value": "v0.3.0", 248 | "date": "2025-01-20T22:55:56Z" 249 | }, 250 | { 251 | "value": "v0.2.0", 252 | "date": "2025-01-19T04:42:55Z" 253 | }, 254 | { 255 | "value": "v0.1.0", 256 | "date": "2025-01-17T19:41:54Z" 257 | } 258 | ] 259 | }, 260 | "le": { 261 | "head": "v0.9.1", 262 | "versions": [ 263 | { 264 | "value": "v0.9.1", 265 | "date": "2025-10-01T13:37:43Z" 266 | }, 267 | { 268 | "value": "v0.9.0", 269 | "date": "2025-09-24T18:15:06Z" 270 | }, 271 | { 272 | "value": "v0.8.0", 273 | "date": "2025-08-22T16:09:09Z" 274 | }, 275 | { 276 | "value": "v0.7.1", 277 | "date": "2025-08-08T16:11:24Z" 278 | }, 279 | { 280 | "value": "v0.7.0", 281 | "date": "2025-07-23T14:10:22Z" 282 | }, 283 | { 284 | "value": "v0.6.0", 285 | "date": "2025-07-02T15:02:09Z" 286 | }, 287 | { 288 | "value": "v0.5.1", 289 | "date": "2025-06-11T16:11:22Z" 290 | }, 291 | { 292 | "value": "v0.5.0", 293 | "date": "2025-06-04T17:34:49Z" 294 | }, 295 | { 296 | "value": "v0.4.0", 297 | "date": "2024-04-26T16:54:48Z" 298 | }, 299 | { 300 | "value": "v0.3.0", 301 | "date": "2024-04-16T13:30:34Z" 302 | }, 303 | { 304 | "value": "v0.2.0", 305 | "date": "2024-04-09T13:07:40Z" 306 | }, 307 | { 308 | "value": "v0.1.0", 309 | "date": "2024-04-02T15:50:01Z" 310 | } 311 | ] 312 | } 313 | } -------------------------------------------------------------------------------- /packages/driver/src/js/overlay/PerformanceOverlay.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useCallback, useMemo, useState } from "react"; 3 | 4 | export interface FrameData { 5 | at: number; 6 | renderTime: number; 7 | } 8 | 9 | export interface LayerStats { 10 | layer: number; 11 | sublayer: number; 12 | totalCommands: number; 13 | drawImageCount: number; 14 | drawImageQuadCount: number; 15 | drawStringCount: number; 16 | } 17 | 18 | export interface RenderStats { 19 | totalLayers: number; 20 | layerStats: LayerStats[]; 21 | lastFrameTime: number; 22 | frameCount: number; 23 | } 24 | 25 | interface PerformanceOverlayProps { 26 | isVisible: boolean; 27 | frames: FrameData[]; 28 | renderStats: RenderStats | null; 29 | onLayerVisibilityChange?: (layer: number, sublayer: number, visible: boolean) => void; 30 | } 31 | 32 | export const PerformanceOverlay: React.FC = ({ 33 | isVisible, 34 | frames, 35 | renderStats, 36 | onLayerVisibilityChange, 37 | }) => { 38 | if (!isVisible) { 39 | return null; 40 | } 41 | 42 | return ( 43 |
44 | 45 | {renderStats && } 46 |
47 | ); 48 | }; 49 | 50 | function LineChart({ data }: { data: FrameData[] }) { 51 | const scaleX = 1; 52 | const scaleY = 1; 53 | 54 | const chart = useMemo(() => { 55 | if (data.length === 0) { 56 | return { 57 | svg: null, 58 | max: 0, 59 | avg: 0, 60 | }; 61 | } 62 | 63 | const ats = data.map(_ => _.at); 64 | const renderTimes = data.map(_ => _.renderTime); 65 | const minX = Math.min(...ats); 66 | const maxX = Math.max(...ats); 67 | const minY = 0; 68 | const maxY = Math.max(...renderTimes); 69 | 70 | const series = data.reduce( 71 | (acc, value, index) => { 72 | if (index > 0) { 73 | acc.push({ 74 | x1: data[index - 1].at, 75 | y1: maxY - data[index - 1].renderTime, 76 | x2: data[index].at, 77 | y2: maxY - data[index].renderTime, 78 | }); 79 | } 80 | return acc; 81 | }, 82 | [] as { x1: number; y1: number; x2: number; y2: number }[], 83 | ); 84 | 85 | return { 86 | svg: ( 87 | 92 | Render performance 93 | {series.map((line, index) => ( 94 | 103 | ))} 104 | 105 | ), 106 | max: maxY, 107 | avg: renderTimes.reduce((acc, value) => acc + value, 0) / renderTimes.length, 108 | }; 109 | }, [data]); 110 | 111 | return ( 112 |
113 | {chart.svg} 114 | {Number.isFinite(chart.max) && ( 115 | 116 | Max {chart.max.toFixed(1)}ms Avg {chart.avg.toFixed(1)}ms 117 | 118 | )} 119 |
120 | ); 121 | } 122 | 123 | function RenderStatsView({ 124 | stats, 125 | onLayerVisibilityChange, 126 | }: { 127 | stats: RenderStats | null; 128 | onLayerVisibilityChange?: (layer: number, sublayer: number, visible: boolean) => void; 129 | }) { 130 | const [showLayers, setShowLayers] = useState(false); 131 | const [layerVisibility, setLayerVisibility] = useState>(new Map()); 132 | 133 | if (!stats) { 134 | return null; 135 | } 136 | 137 | const summary = { 138 | totalLayers: stats.totalLayers, 139 | totalDrawImage: stats.layerStats.reduce((sum, layer) => sum + layer.drawImageCount, 0), 140 | totalDrawImageQuad: stats.layerStats.reduce((sum, layer) => sum + layer.drawImageQuadCount, 0), 141 | totalDrawString: stats.layerStats.reduce((sum, layer) => sum + layer.drawStringCount, 0), 142 | frameTime: stats.lastFrameTime.toFixed(1), 143 | frameCount: stats.frameCount, 144 | }; 145 | const layerDetails = stats.layerStats; 146 | 147 | const totalDrawCalls = summary.totalDrawImage + summary.totalDrawImageQuad + summary.totalDrawString; 148 | 149 | const toggleLayerVisibility = useCallback( 150 | (layer: number, sublayer: number) => { 151 | const layerKey = `${layer}.${sublayer}`; 152 | const currentVisibility = layerVisibility.get(layerKey) ?? true; 153 | const newVisibility = !currentVisibility; 154 | 155 | setLayerVisibility(prev => { 156 | const newMap = new Map(prev); 157 | newMap.set(layerKey, newVisibility); 158 | return newMap; 159 | }); 160 | 161 | onLayerVisibilityChange?.(layer, sublayer, newVisibility); 162 | }, 163 | [layerVisibility, onLayerVisibilityChange], 164 | ); 165 | 166 | return ( 167 |
168 |
Render Stats (Frame #{summary.frameCount})
169 |
170 |
Layers: {summary.totalLayers}
171 |
Frame: {summary.frameTime}ms
172 |
Total draws: {totalDrawCalls}
173 |
Images: {summary.totalDrawImage}
174 |
Quads: {summary.totalDrawImageQuad}
175 |
Text: {summary.totalDrawString}
176 |
177 | 178 | {layerDetails.length > 0 && ( 179 |
180 | 187 | {showLayers && ( 188 |
189 | {layerDetails.map((layer: LayerStats) => { 190 | const layerTotal = layer.drawImageCount + layer.drawImageQuadCount + layer.drawStringCount; 191 | if (layerTotal === 0) return null; 192 | 193 | const layerKey = `${layer.layer}.${layer.sublayer}`; 194 | const isVisible = layerVisibility.get(layerKey) ?? true; 195 | 196 | return ( 197 |
198 | 207 | 208 | L{layer.layer}.{layer.sublayer}: {layer.totalCommands}c {layerTotal}d (I{layer.drawImageCount} Q 209 | {layer.drawImageQuadCount} T{layer.drawStringCount}) 210 | 211 |
212 | ); 213 | })} 214 |
215 | )} 216 |
217 | )} 218 |
219 | ); 220 | } 221 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pob.cool - Path of Building for browser environment 2 | 3 | [![wakatime](https://wakatime.com/badge/user/018dace5-5642-4ac8-88a7-2ec0a867f8a7/project/fa7418b8-8ddb-479c-805b-ce2043f24d24.svg)](https://wakatime.com/badge/user/018dace5-5642-4ac8-88a7-2ec0a867f8a7/project/fa7418b8-8ddb-479c-805b-ce2043f24d24) 4 | 5 | This is browser version of [Path of Building](https://pathofbuilding.community/). 6 | 7 | ## Features 8 | 9 | - Run the PoB in your browser, that's all. 10 | - You can select the version of the PoB to run. 11 | - Saved builds are stored in the browser's local storage. 12 | - The `Cloud` folder appears when you are logged into the site. Builds saved there are stored in the cloud and can be accessed from anywhere. 13 | - You can load a build by specifying a hash in the URL. 14 | - eg. https://pob.cool/#build=https://pobb.in/WwTAYwulVav6 15 | 16 | ## Limitations 17 | 18 | - Network access is through our CORS proxy, so all users have the same source IP. This will likely cause rate limiting. 19 | - For security reasons, requests containing the POESESSID cookie will be unconditionally rejected. Do not enter POESESSID in the PoB of this site. 20 | 21 | ## Principle 22 | 23 | - We will not make any changes to the original PoB. This is because a lot of effort has been put into the PoB itself and 24 | we want the community to focus on developing the offline version. 25 | - However, it does make changes in behavior that are possible without changing the code. 26 | 27 | ## Development 28 | 29 | ### Prerequisites 30 | 31 | - [Mise](https://mise.jdx.dev/) 32 | 33 | ### Clone the repository 34 | 35 | This repository includes a [submodule](https://gist.github.com/gitaarik/8735255) in `vendor/lua`. To include the submodule when cloning the repository, use the `--recurse-submodules` flag: 36 | 37 | ```bash 38 | git clone --recurse-submodules 39 | ``` 40 | 41 | If you omitted the flag, you can use the following commands to clone the submodule: 42 | 43 | ```bash 44 | git submodule init 45 | git submodule update 46 | ``` 47 | 48 | ### Install dependencies 49 | 50 | ```bash 51 | mise install 52 | hk install --mise 53 | ``` 54 | 55 | ### Pack upstream PoB 56 | 57 | Before running the development server, you need to pack the upstream PoB assets into a structure that the driver can use. 58 | 59 | ```bash 60 | mise run pack --game poe2 --tag v0.8.0 61 | ``` 62 | 63 | ### Run driver shell 64 | 65 | Set up a development server for the PoB web driver alone. 66 | 67 | ```bash 68 | mise run driver:dev --game poe2 --version v0.8.0 69 | ``` 70 | 71 | ### Run web app 72 | 73 | Set up a web application development server. 74 | You need to build the driver first. 75 | 76 | ```bash 77 | mise run web:dev 78 | ``` 79 | 80 | ### pob.cool maintenance for owners 81 | 82 | If you are the owner of pob.cool, you can set `MISE_ENV=pob-cool` to enable mise tasks. 83 | 84 | ## Architecture 85 | 86 | ```mermaid 87 | graph TB 88 | subgraph "Browser Environment" 89 | subgraph "packages/web - React Web App" 90 | WEB[React Router App] 91 | AUTH[Auth0 Integration] 92 | CLOUD[Cloud Storage] 93 | end 94 | 95 | subgraph "packages/driver - PoB Driver" 96 | DRIVER[Driver Class] 97 | CANVAS[Canvas Manager] 98 | EVENT[Event Handler] 99 | OVERLAY[React Overlays] 100 | WEBGL[WebGL Renderer] 101 | end 102 | 103 | subgraph "WebAssembly Runtime" 104 | WASM[Lua 5.2 Interpreter] 105 | CBRIDGE[C Bridge Module] 106 | POBCODE[Original PoB Lua Code] 107 | end 108 | 109 | subgraph "Asset Management" 110 | PACKER[packages/packer] 111 | ASSETS[Packed Assets] 112 | CDN[asset.pob.cool CDN] 113 | end 114 | 115 | subgraph "Game Data" 116 | GAMEDATA[packages/game] 117 | POE1[Path of Exile 1] 118 | POE2[Path of Exile 2] 119 | LE[Last Epoch] 120 | end 121 | 122 | subgraph "External Services" 123 | GITHUB[GitHub Repositories] 124 | CORS[CORS Proxy] 125 | PATHOFEXILE[Path of Exile API] 126 | end 127 | end 128 | 129 | subgraph "Storage" 130 | LOCAL[localStorage] 131 | KV[Cloudflare KV] 132 | end 133 | 134 | %% Main data flow 135 | WEB --> DRIVER 136 | DRIVER --> WASM 137 | WASM --> POBCODE 138 | CBRIDGE --> DRIVER 139 | DRIVER --> WEBGL 140 | DRIVER --> CANVAS 141 | DRIVER --> EVENT 142 | DRIVER --> OVERLAY 143 | 144 | %% Asset flow 145 | PACKER --> ASSETS 146 | ASSETS --> CDN 147 | CDN --> DRIVER 148 | GITHUB --> PACKER 149 | 150 | %% Game data flow 151 | GAMEDATA --> POE1 152 | GAMEDATA --> POE2 153 | GAMEDATA --> LE 154 | GAMEDATA --> WEB 155 | 156 | %% Storage flow 157 | WEB --> LOCAL 158 | AUTH --> CLOUD 159 | CLOUD --> KV 160 | 161 | %% Network flow 162 | DRIVER --> CORS 163 | CORS --> PATHOFEXILE 164 | 165 | %% Styling 166 | classDef webPackage fill:#e1f5fe 167 | classDef driverPackage fill:#f3e5f5 168 | classDef wasmPackage fill:#fff3e0 169 | classDef assetPackage fill:#e8f5e8 170 | classDef gamePackage fill:#fce4ec 171 | classDef external fill:#f5f5f5 172 | classDef storage fill:#fff8e1 173 | 174 | class WEB,AUTH,CLOUD webPackage 175 | class DRIVER,CANVAS,EVENT,OVERLAY,WEBGL driverPackage 176 | class WASM,CBRIDGE,POBCODE wasmPackage 177 | class PACKER,ASSETS,CDN assetPackage 178 | class GAMEDATA,POE1,POE2,LE gamePackage 179 | class GITHUB,CORS,PATHOFEXILE external 180 | class LOCAL,KV storage 181 | ``` 182 | 183 | ## Under the hood 184 | 185 | ### Core Architecture 186 | 187 | **WebAssembly Runtime**: The heart of pob-web is a custom Lua 5.2 interpreter compiled to WebAssembly using Emscripten. This allows the original Path of Building Lua codebase to run unmodified in the browser, maintaining 100% compatibility with the desktop version. 188 | 189 | **C Bridge Layer**: A critical component written in C (`packages/driver/src/c/`) acts as a bridge between the Lua runtime and the JavaScript driver. This includes: 190 | - Custom implementations of PoB's graphics modules (equivalent to SimpleGraphic) 191 | - File system abstraction using Emscripten's WASMFS 192 | - Memory management and data marshaling between Lua and JavaScript contexts 193 | 194 | **JavaScript Driver**: The `packages/driver` emulates the desktop PoB window environment using vanilla JavaScript and WebGL: 195 | - **Canvas Management**: Handles multiple rendering contexts and viewport management 196 | - **Event System**: Translates browser events (mouse, keyboard, touch) to PoB-compatible input 197 | - **WebGL Renderer**: Hardware-accelerated rendering pipeline that interprets PoB's drawing commands 198 | - **React Overlays**: Mobile-optimized UI components (virtual keyboard, zoom controls) with scoped CSS 199 | 200 | ### Asset Pipeline 201 | 202 | **Upstream Integration**: The `packages/packer` tool automatically processes releases from upstream PoB repositories: 203 | - Downloads and extracts game assets, Lua scripts, and data files 204 | - Compresses textures and optimizes assets for web delivery 205 | - Generates manifest files for efficient loading 206 | - Supports multiple games (PoE1, PoE2, Last Epoch) with version management 207 | 208 | **Content Delivery**: Assets are served via CDN (asset.pob.cool) in production, with local filesystem fallback during development using Vite's virtual filesystem. 209 | 210 | ### Web Application 211 | 212 | **React Frontend**: The `packages/web` provides the user-facing application: 213 | - React Router v7 with server-side rendering capabilities 214 | - Cloudflare Pages deployment with Workers functions for API endpoints 215 | - Auth0 integration for user authentication and cloud storage 216 | - Build management with localStorage and optional Cloudflare KV cloud sync 217 | 218 | ### Key Engineering Challenges Solved 219 | 220 | 1. **Memory Management**: Efficient data transfer between WebAssembly heap and JavaScript objects 221 | 2. **Graphics Translation**: Converting PoB's immediate-mode graphics calls to WebGL draw commands 222 | 3. **File System Emulation**: Providing a POSIX-like filesystem interface within browser constraints 223 | 4. **Mobile Adaptation**: Touch-friendly overlays without modifying the original PoB interface 224 | 5. **Network Isolation**: CORS proxy for external API calls while maintaining security 225 | 6. **Asset Optimization**: Balancing file size with loading performance for large game databases 226 | 227 | This architecture enables running complex desktop software in the browser while maintaining the principle of zero modifications to the original PoB codebase. 228 | -------------------------------------------------------------------------------- /.github/workflows/sync-upstream.yml: -------------------------------------------------------------------------------- 1 | name: Check for new releases 2 | on: 3 | schedule: 4 | - cron: '0 * * * *' 5 | workflow_dispatch: 6 | inputs: 7 | dry_run: 8 | description: 'Perform a dry run without making any changes' 9 | required: false 10 | default: 'true' 11 | 12 | jobs: 13 | check-release: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | game: [ poe1, poe2, le ] 18 | env: 19 | VERSION_FILE: "version.json" 20 | GAME: ${{ matrix.game }} 21 | UPDATE_HEAD: "true" 22 | DRY_RUN: "${{ github.event.inputs.dry_run || 'false' }}" 23 | steps: 24 | - uses: actions/create-github-app-token@v1 25 | id: app-token 26 | with: 27 | app-id: ${{ secrets.TOKEN_APP_ID }} 28 | private-key: ${{ secrets.TOKEN_PRIVATE_KEY }} 29 | 30 | - uses: actions/checkout@v4 31 | with: 32 | token: ${{ steps.app-token.outputs.token }} 33 | 34 | - name: Cache npm dependencies 35 | uses: actions/cache@v4 36 | with: 37 | key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-npm- 40 | path: | 41 | ~/.npm 42 | 43 | - uses: jdx/mise-action@v2 44 | - run: mise run install 45 | 46 | - name: Get latest releases 47 | id: get_releases 48 | uses: actions/github-script@v7 49 | with: 50 | script: | 51 | let gamesJson = ""; 52 | await exec.exec("mise", ["run", "print-game-data"], { 53 | listeners: { 54 | stdout: (data) => { 55 | gamesJson += data.toString(); 56 | }, 57 | }, 58 | }); 59 | const games = JSON.parse(gamesJson); 60 | 61 | const { owner, name: repo } = games[process.env.GAME].repository; 62 | if (!owner || !repo) { 63 | throw new Error('Invalid game specified.'); 64 | } 65 | 66 | const tags = []; 67 | 68 | const query = ` 69 | query($owner: String!, $repo: String!, $cursor: String) { 70 | repository(owner: $owner, name: $repo) { 71 | refs( 72 | refPrefix: "refs/tags/", 73 | first: 100, 74 | after: $cursor, 75 | ) { 76 | totalCount 77 | pageInfo { 78 | hasNextPage 79 | endCursor 80 | } 81 | nodes { 82 | name 83 | target { 84 | ... on Commit { 85 | committedDate 86 | } 87 | ... on Tag { 88 | target { 89 | ... on Commit { 90 | committedDate 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | `; 100 | 101 | let cursor = null; 102 | 103 | while (true) { 104 | const result = await github.graphql(query, { 105 | owner, 106 | repo, 107 | cursor, 108 | }); 109 | 110 | for (const node of result.repository.refs.nodes) { 111 | const date = node.target?.target?.committedDate || node.target?.committedDate; 112 | if (date) { 113 | tags.push({ name: node.name, committedDate: date }); 114 | } 115 | } 116 | 117 | const pageInfo = result.repository.refs.pageInfo; 118 | if (!pageInfo.hasNextPage) { 119 | break; 120 | } 121 | cursor = pageInfo.endCursor; 122 | } 123 | 124 | tags.sort((a, b) => new Date(b.committedDate) - new Date(a.committedDate)); 125 | 126 | const latestTags = tags.slice(0, 10); 127 | 128 | core.info(`Latest 10 tags: ${JSON.stringify(latestTags, null, 2)}`); 129 | return latestTags; 130 | 131 | - name: Determine new versions 132 | id: new_versions 133 | env: 134 | LATEST_TAGS: ${{ steps.get_releases.outputs.result }} 135 | uses: actions/github-script@v7 136 | with: 137 | script: | 138 | const fs = require('fs'); 139 | 140 | const latestTags = JSON.parse(process.env.LATEST_TAGS); 141 | 142 | const versions = JSON.parse(fs.readFileSync(process.env.VERSION_FILE, 'utf8')); 143 | const knownVersions = versions[process.env.GAME].versions; 144 | 145 | const newTags = latestTags.filter(tag => !knownVersions.some(known => known.value === tag.name)); 146 | core.info(`New tags: ${JSON.stringify(newTags, null, 2)}`); 147 | 148 | if (newTags.length === 0) { 149 | core.notice(`No new tags found for ${process.env.GAME}.`); 150 | return null; 151 | } else { 152 | core.summary.addHeading(`New tags for ${process.env.GAME}`, '2'); 153 | const table = [ 154 | [ 155 | { data: "Tag", header: true }, 156 | { data: "Date", header: true }, 157 | ], 158 | newTags.flatMap(tag => [ 159 | { data: tag.name }, 160 | { data: tag.committedDate }, 161 | ]), 162 | ]; 163 | core.summary.addTable(table); 164 | } 165 | 166 | return newTags.length > 0 ? newTags : null; 167 | 168 | - name: Pack and sync new versions 169 | if: steps.new_versions.outputs.result != 'null' 170 | env: 171 | NEW_TAGS: ${{ steps.new_versions.outputs.result }} 172 | R2_ENDPOINT_URL: https://621480c42e70995622a2d0a86bb7751c.r2.cloudflarestorage.com 173 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 174 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 175 | MISE_SOPS_STRICT: "false" 176 | MISE_ENV: pob-cool 177 | uses: actions/github-script@v7 178 | with: 179 | script: | 180 | const newTags = JSON.parse(process.env.NEW_TAGS); 181 | for (const tag of newTags) { 182 | await exec.exec(`mise run pack --game ${process.env.GAME} --tag ${tag.name}`); 183 | if (process.env.DRY_RUN !== 'true') { 184 | await exec.exec(`mise run sync --game ${process.env.GAME} --tag ${tag.name}`); 185 | } 186 | } 187 | 188 | - name: Update version.json 189 | if: steps.new_versions.outputs.result != 'null' 190 | env: 191 | NEW_TAGS: ${{ steps.new_versions.outputs.result }} 192 | R2_ENDPOINT_URL: https://621480c42e70995622a2d0a86bb7751c.r2.cloudflarestorage.com 193 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 194 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 195 | uses: actions/github-script@v7 196 | with: 197 | script: | 198 | const fs = require('fs'); 199 | 200 | const newTags = JSON.parse(process.env.NEW_TAGS); 201 | const versions = JSON.parse(fs.readFileSync(process.env.VERSION_FILE, 'utf8')); 202 | 203 | versions[process.env.GAME].versions = [ 204 | ...newTags.map(tag => ({ value: tag.name, date: tag.committedDate })), 205 | ...versions[process.env.GAME].versions, 206 | ]; 207 | 208 | if (process.env.UPDATE_HEAD === 'true') { 209 | versions[process.env.GAME].head = newTags[0].name; 210 | } 211 | 212 | core.info(`Updated versions: ${JSON.stringify(versions, null, 2)}`); 213 | fs.writeFileSync(process.env.VERSION_FILE, JSON.stringify(versions, null, 2)); 214 | 215 | if (process.env.DRY_RUN !== 'true') { 216 | await exec.exec(`aws s3 cp --checksum-algorithm=CRC32 --region auto --endpoint-url ${process.env.R2_ENDPOINT_URL} version.json s3://pob-web/version.json`); 217 | } 218 | 219 | - name: Commit changes 220 | if: steps.new_versions.outputs.result != 'null' && env.DRY_RUN != 'true' 221 | env: 222 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 223 | run: | 224 | git config user.name "atty303-bot[bot]" 225 | git config user.email "atty303-bot[bot]@users.noreply.github.com" 226 | git add $VERSION_FILE 227 | git commit -m "Update version.json with new releases (update head: $UPDATE_HEAD)" 228 | git push 229 | -------------------------------------------------------------------------------- /packages/driver/src/js/fs.ts: -------------------------------------------------------------------------------- 1 | import type { Backend } from "@zenfs/core"; 2 | import * as zenfs from "@zenfs/core"; 3 | import { Errno, ErrnoError, Stats } from "@zenfs/core"; 4 | import { log, tag } from "./logger"; 5 | 6 | class FetchError extends Error { 7 | constructor( 8 | public readonly response: Response, 9 | message?: string, 10 | ) { 11 | super(message || `${response.status}: ${response.statusText}`); 12 | } 13 | } 14 | 15 | function statsToMetadata(stats: zenfs.StatsLike) { 16 | return { 17 | atimeMs: stats.atimeMs, 18 | mtimeMs: stats.mtimeMs, 19 | ctimeMs: stats.ctimeMs, 20 | birthtimeMs: stats.birthtimeMs, 21 | uid: stats.uid, 22 | gid: stats.gid, 23 | size: stats.size, 24 | mode: stats.mode, 25 | ino: stats.ino, 26 | }; 27 | } 28 | 29 | export class CloudflareKVFileSystem extends zenfs.FileSystem { 30 | private readonly fetch: ( 31 | method: string, 32 | path: string, 33 | body?: Uint8Array, 34 | headers?: Record, 35 | ) => Promise; 36 | private cache: Map = new Map(); 37 | 38 | constructor( 39 | readonly prefix: string, 40 | readonly token: string, 41 | readonly ns: string | undefined, 42 | ) { 43 | super(0x43464b56 /*CFKV*/, "cloudflare-kvfs"); 44 | this.fetch = (method: string, path: string, body?: Uint8Array, headers?: Record) => { 45 | log.debug(tag.kvfs, "fetch", method, path); 46 | const url = `${prefix}${path}`; 47 | return fetch(url, { 48 | method, 49 | body, 50 | headers: { 51 | Authorization: `Bearer ${token}`, 52 | ...(ns ? { "x-user-namespace": ns } : {}), 53 | ...(headers ?? {}), 54 | }, 55 | }); 56 | }; 57 | } 58 | 59 | async ready(): Promise { 60 | await this.reload(); 61 | } 62 | 63 | async reload(): Promise { 64 | this.cache = new Map(await this.readList()); 65 | } 66 | 67 | async rename(oldPath: string, newPath: string): Promise { 68 | log.debug(tag.kvfs, "rename", { oldPath, newPath }); 69 | const oldFile = await this.openFile(oldPath, "r"); 70 | const stats = await oldFile.stat(); 71 | const buffer = new Uint8Array(stats.size); 72 | await oldFile.read(buffer, 0, stats.size, 0); 73 | await oldFile.close(); 74 | 75 | const newFile = await this.createFile(newPath, "w", stats.mode); 76 | await newFile.write(buffer, 0, buffer.length, 0); 77 | await newFile.close(); 78 | 79 | await this.unlink(oldPath); 80 | this.cache.delete(oldPath); 81 | this.cache.set(newPath, stats); 82 | } 83 | renameSync(_oldPath: string, _newPath: string): void { 84 | throw new Error("Method not implemented."); 85 | } 86 | 87 | async stat(path: string): Promise { 88 | return this.statSync(path); 89 | } 90 | statSync(path: string): zenfs.Stats { 91 | const stats = this.cache.get(path); 92 | if (!stats) { 93 | throw new ErrnoError(Errno.ENOENT, "path", path, "stat"); 94 | } 95 | return stats; 96 | } 97 | 98 | async openFile(path: string, flag: string): Promise { 99 | log.debug(tag.kvfs, "openFile", { path, flag }); 100 | let buffer: ArrayBufferLike; 101 | let stats = this.cache.get(path); 102 | if (zenfs.isWriteable(flag)) { 103 | buffer = new ArrayBuffer(0); 104 | stats = new zenfs.Stats({ mode: 0o777 | zenfs.constants.S_IFREG, size: 0 }); 105 | this.cache.set(path, stats); 106 | } else { 107 | if (!stats) { 108 | throw ErrnoError.With("ENOENT", path, "openFile"); 109 | } 110 | if (!stats.hasAccess(zenfs.flagToMode(flag))) { 111 | throw ErrnoError.With("EACCES", path, "openFile"); 112 | } 113 | const r = await this.fetch("GET", path); 114 | if (!r.ok) { 115 | throw new FetchError(r); 116 | } 117 | buffer = await (await r.blob()).arrayBuffer(); 118 | } 119 | 120 | return new zenfs.PreloadFile(this, path, flag, stats, new Uint8Array(buffer)); 121 | } 122 | openFileSync(_path: string, _flag: string): zenfs.File { 123 | throw new Error("Method not implemented."); 124 | } 125 | 126 | async createFile(path: string, flag: string, mode: number): Promise { 127 | log.debug(tag.kvfs, "createFile", { path, flag, mode }); 128 | const data = new Uint8Array(0); 129 | const r = await this.fetch("PUT", path, data); 130 | if (!r.ok) { 131 | throw new FetchError(r); 132 | } 133 | const stats = new zenfs.Stats({ mode: mode | zenfs.constants.S_IFREG, size: 0 }); 134 | this.cache.set(path, stats); 135 | return new zenfs.PreloadFile(this, path, flag, stats, data); 136 | } 137 | createFileSync(_path: string, _flag: string, _mode: number): zenfs.File { 138 | throw new Error("Method not implemented."); 139 | } 140 | 141 | async unlink(path: string): Promise { 142 | log.debug(tag.kvfs, "unlink", { path }); 143 | const r = await this.fetch("DELETE", path); 144 | if (!r.ok) { 145 | throw new FetchError(r); 146 | } 147 | this.cache.delete(path); 148 | } 149 | unlinkSync(_path: string): void { 150 | throw new Error("Method not implemented."); 151 | } 152 | 153 | async rmdir(path: string): Promise { 154 | log.debug(tag.kvfs, "rmdir", { path }); 155 | const r = await this.fetch("DELETE", path); 156 | if (!r.ok) { 157 | throw new FetchError(r); 158 | } 159 | this.cache.delete(path); 160 | } 161 | rmdirSync(_path: string): void { 162 | throw new Error("Method not implemented."); 163 | } 164 | 165 | async mkdir(path: string, mode: number): Promise { 166 | log.debug(tag.kvfs, "mkdir", { path, mode }); 167 | const stats = new zenfs.Stats({ mode: mode | zenfs.constants.S_IFDIR, size: 4096 }); 168 | const r = await this.fetch("PUT", path, new Uint8Array(0), { 169 | "x-metadata": JSON.stringify(statsToMetadata(stats)), 170 | }); 171 | if (!r.ok) { 172 | throw new FetchError(r); 173 | } 174 | this.cache.set(path, stats); 175 | } 176 | mkdirSync(_path: string, _mode: number): void { 177 | throw new Error("Method not implemented."); 178 | } 179 | 180 | async readdir(path: string): Promise { 181 | log.debug(tag.kvfs, "readdir", { path }); 182 | return this.readdirSync(path); 183 | } 184 | readdirSync(path: string): string[] { 185 | const prefix = !path.endsWith("/") ? `${path}/` : path; 186 | return [...this.cache.keys()] 187 | .filter(_ => _.startsWith(prefix) && _.substring(prefix.length).split("/").length === 1) 188 | .map(_ => _.substring(prefix.length)) 189 | .filter(_ => _.length > 0); 190 | } 191 | 192 | link(_srcpath: string, _dstpath: string): Promise { 193 | throw new Error("Method not implemented."); 194 | } 195 | linkSync(_srcpath: string, _dstpath: string): void { 196 | throw new Error("Method not implemented."); 197 | } 198 | 199 | async sync(path: string, data: Uint8Array, stats: Readonly): Promise { 200 | log.debug(tag.kvfs, "sync", { path, data, stats }); 201 | const metadata = statsToMetadata(stats); 202 | const body = new Uint8Array(data.byteLength); 203 | body.set(data); 204 | const r = await this.fetch("PUT", path, body, { "x-metadata": JSON.stringify(metadata) }); 205 | if (!r.ok) { 206 | log.error(tag.kvfs, "sync", path, r.status, r.statusText); 207 | throw ErrnoError.With("EIO", path, "sync"); 208 | } 209 | this.cache.set(path, new Stats(stats)); 210 | } 211 | syncSync(_path: string, _data: Uint8Array, _stats: Readonly): void { 212 | throw new Error("Method not implemented."); 213 | } 214 | 215 | protected async readList() { 216 | const r = await this.fetch("GET", ""); 217 | if (!r.ok) { 218 | throw new FetchError(r); 219 | } 220 | const list = [ 221 | ["/", new zenfs.Stats({ mode: 0o777 | zenfs.constants.S_IFDIR, size: 4096 })], 222 | ...(await r.json()).map((_: { name: string; metadata: { dir: boolean; size: number } }) => [ 223 | `/${_.name}`, 224 | new zenfs.Stats(_.metadata), 225 | ]), 226 | ]; 227 | log.debug(tag.kvfs, "readList", { list }); 228 | return list; 229 | } 230 | 231 | read(_path: string, _buffer: Uint8Array, _offset: number, _end: number): Promise { 232 | throw new Error("Method not implemented."); 233 | } 234 | 235 | readSync(_path: string, _buffer: Uint8Array, _offset: number, _end: number): void { 236 | throw new Error("Method not implemented."); 237 | } 238 | 239 | write(_path: string, _buffer: Uint8Array, _offset: number): Promise { 240 | throw new Error("Method not implemented."); 241 | } 242 | 243 | writeSync(_path: string, _buffer: Uint8Array, _offset: number): void { 244 | throw new Error("Method not implemented."); 245 | } 246 | } 247 | 248 | export interface CloudflareKVOptions { 249 | prefix: string; 250 | token: string; 251 | namespace?: string; 252 | } 253 | 254 | export const CloudflareKV = { 255 | name: "CloudflareKV", 256 | 257 | options: { 258 | prefix: { 259 | type: "string", 260 | required: true, 261 | description: "The URL prefix to use for requests", 262 | }, 263 | token: { 264 | type: "string", 265 | required: true, 266 | description: "The JWT token to use", 267 | }, 268 | namespace: { 269 | type: "string", 270 | required: false, 271 | description: "The user namespace to use", 272 | }, 273 | }, 274 | 275 | isAvailable(): boolean { 276 | return true; 277 | }, 278 | 279 | create(options: CloudflareKVOptions) { 280 | return new CloudflareKVFileSystem(options.prefix, options.token, options.namespace); 281 | }, 282 | } as const satisfies Backend; 283 | --------------------------------------------------------------------------------