14 |
15 |
22 |
23 |
Controls & Navigation
24 |
25 |
26 |
27 |
Touch Controls
28 |
29 |
30 |
31 |
32 | | Operation |
33 | Pointer Mode OFF |
34 | Pointer Mode ON |
35 |
36 |
37 |
38 |
39 | | Left Click |
40 | Single tap |
41 | - |
42 |
43 |
44 | | Right Click |
45 | Long press (300ms) |
46 | - |
47 |
48 |
49 | | Pointer Move |
50 | - |
51 | Single finger movement |
52 |
53 |
54 | | Drag |
55 | Single finger movement |
56 | - |
57 |
58 |
59 | | Wheel |
60 | Two finger vertical drag |
61 | - |
62 |
63 |
64 | | Zoom |
65 | Two finger pinch |
66 | Two finger pinch |
67 |
68 |
69 | | Pan Canvas |
70 | Three finger drag |
71 | Three finger drag |
72 |
73 |
74 |
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 |
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 |
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 |
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 |
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