diff --git a/apps/launcher/src/components/MapStylePreview.vue b/apps/launcher/src/components/MapStylePreview.vue index a822e4d3..100d0ef0 100644 --- a/apps/launcher/src/components/MapStylePreview.vue +++ b/apps/launcher/src/components/MapStylePreview.vue @@ -8,7 +8,7 @@ const boundary = "M0 120 L50 90 L92 104 L138 54 L190 70 L230 28 L320 40"; diff --git a/apps/launcher/src/components/MapsSettings.test.ts b/apps/launcher/src/components/MapsSettings.test.ts index 2f97e76d..d2e571cc 100644 --- a/apps/launcher/src/components/MapsSettings.test.ts +++ b/apps/launcher/src/components/MapsSettings.test.ts @@ -85,7 +85,7 @@ describe("Maps settings", () => { expect(wrapper.text()).toContain("Layers and styles for every account"); expect(wrapper.text()).toContain("World Map"); expect(wrapper.text()).toContain("Exploration grid"); - expect(wrapper.text()).toContain("unexplored cells reachable in this instance"); + expect(wrapper.text()).toContain("nearby unexplored cells that this map can reveal"); expect(wrapper.text()).toContain("Walkable terrain"); expect(wrapper.text()).not.toContain("Other-map route"); diff --git a/apps/launcher/src/components/MapsSettings.vue b/apps/launcher/src/components/MapsSettings.vue index 3b56914d..673100a3 100644 --- a/apps/launcher/src/components/MapsSettings.vue +++ b/apps/launcher/src/components/MapsSettings.vue @@ -64,8 +64,6 @@ const confirmDelete = ref(false); const active = computed(() => resolveCartographyPresetEntry(props.settings.cartographyPresetLibrary)); const exportValue = computed(() => active.value ? encodeCartographyPreset({ name: active.value.name, style: active.value.style }) : ""); const lineLabels = { - lattice: "Grid lines", - current: "Current cell", hover: "Hovered cell", normalRange: "3×3 range", birdsEyeRange: "7×7 range", @@ -198,11 +196,11 @@ async function updateUnseen(patch: Partial
- +
- +
Configure Compass ranges @@ -239,7 +237,7 @@ async function updateUnseen(patch: PartialGrid outline color
Unseen cell color
-
Advanced grid lines +
Inspection outlines
{{ label }}
Color
diff --git a/docs/cartography.md b/docs/cartography.md index 6043ee19..04fb0789 100644 --- a/docs/cartography.md +++ b/docs/cartography.md @@ -3,7 +3,8 @@ GWonMac has three independent native-map layers: - **Cartography grid** draws Guild Wars' fixed 32×32 map-unit exploration cells - over the Mission Map and World Map. **Grid on Compass** includes the Compass + only where exploration remains on the Mission Map and World Map. **Markers on + Compass** includes the Compass only when explicitly enabled. It defaults to off, including existing profiles that do not yet have this preference. - **Walkable terrain** shades the current instance's certified pathing geometry @@ -26,8 +27,8 @@ without a reload when Tools and Maps are active. Maps settings remain available when Tools are off. The page explains how to enable Maps and whether an application restart is needed. An illustrative -preview shows the selected colors, border, grid, and markers without a game -session. Advanced grid lines and Compass inspection options are disclosed +preview shows the selected colors, border, and remaining markers without a game +session. Inspection outlines and Compass inspection options are disclosed separately. **Use Cartographer defaults** selects the built-in style without deleting custom styles. Deletion requires confirmation with the style name. @@ -69,15 +70,16 @@ the remembered `2` remains the basis for the lighter non-current number. The default visual language is: -- soft green coverage: creditable progress already explored; +- explored cells: no exploration artwork; - hollow amber: an unexplored continent candidate; - solid orange: confirmed actionable in the loaded instance; and - no grey marker. Continent progress remains visible if current pathing is unavailable. Missing Compass or map-window projection hides only that surface. Amber is an estimate, -not proof that the current instance can reach a cell. A thin neutral boundary -shows where live current-instance evidence applies. +not proof that the current instance can reach a cell. Existing marker shapes and +colors distinguish live evidence from estimates; no permanent lattice or +explored-area tint covers the map. ## Map presentation @@ -88,8 +90,8 @@ on either map hides only that surface. Continent progress and guidance cover Tyria, Pre-Searing, Cantha, and Elona. The Battle Isles, Realm of Torment, dungeons, and other maps outside a campaign -world map keep the local walkable-terrain layer but hide the global grid, -coverage, and guidance. The compact control remains visible and explains this +world map keep the local walkable-terrain layer but hide global exploration +guidance. The compact control remains visible and explains this limited mode when opened. Settings remain intact and apply again automatically after travel to a fully supported area. @@ -104,13 +106,14 @@ At 18 pixels or more per cell, the map draws individual amber diamonds and orange actionable markers. At 8–18 pixels it groups the global grid into 4×4 clusters. Below 8 pixels it uses 16×16 clusters. Cluster origins are fixed to the global grid, so they do not jump during pan, resize, or travel. Unreadable -progress hides below the minimum safe scale. +progress hides below the minimum safe scale. Large cached tiles use clusters +when individual markers exceed the drawing budget, so resizing does not erase +remaining guidance. -The detailed walkability veil is shown only in close-up presentation. The -continent-scale view keeps green coverage, remaining clusters, solid-orange -current guidance, and the live-evidence boundary without the noisy terrain -veil. Off-screen cells are culled; green coverage is cached by exploration -generation. +The detailed walkability veil is shown on the Compass and Mission Map. It +darkens the area outside walkable terrain while leaving the interior clear, +with the chosen boundary color. The continent-scale view keeps remaining +markers and clusters. Explored cells are clear on every map. Hold Shift while hovering the map window to inspect the normal 3×3 reveal range. Hold Option+Shift for Bird's Eye 7×7 inspection. The diamond is the @@ -119,7 +122,44 @@ hatching. The Compass stays local and precise. It uses the same fixed grid, live terrain, exploration, and actionable state, but does not draw continent clusters or the -green continent tint. It works before the map window is opened. +continent tint. It works before the map window is opened. + +Terrain, remaining markers, and ranges are composed inside their owning native +maps. Compass terrain and markers share a native Canvas texture; ranges have a +separate retained mesh. Mission and World Map bitmap quads draw after their +native background and before foreground icons. Native matrices and clipping +control continuous motion and coverage by later panels and tooltips. The old +positioned browser drawing surfaces are removed. Menus and tooltips remain +application UI. Compass and Mission Map share the inverse-veil painter. + +The Compass caches one nearby terrain texture. It repaints when the map, terrain +tile, style, opacity, or interface scale changes. Movement within a tile and +camera rotation update only native mesh coordinates. Optional Compass inspection +repaints when its selected player cell changes; it does not follow every subcell +movement in the CPU painter. The texture has a fixed +size limit, and its input buffer is released after the native copy. The Canvas +owns the retained mesh and material until its destructor runs. Missing native +support or stale map identity hides Compass terrain without changing settings. +Stationary Compass geometry is reused; range geometry changes only with its +Canvas rectangle or artwork, not camera motion. + +Mission and World Map cache artwork in world coordinates, with a margin around +the visible rectangle. Small pans reuse it. Zoom moves it continuously through +the native camera; raster detail changes in eighth-octave steps. Textures are +bounded to 2048 pixels per edge. Hover and Shift inspection use independent +small textures, so moving the pointer does not rebuild terrain or remaining +markers. Closing a map withdraws its drawing; native destruction releases its +retained handles. Graphics resets invalidate uploads, and loader disposal frees +its detached canvases and listeners. Failed installation releases each resource +already acquired; one failed native withdrawal does not stop other cleanup. +Missing native support hides that surface and skips its CPU painter. Compass +geometry remains available for a healthy Mission Map projection. + +Exploration invalidation compares bitmap contents rather than the observation +sequence. Identical polls reuse the masks and artwork; real progress updates +at the existing model polling interval. Native presentation remains at the +game's frame rate. Cached textures trade bounded memory and slight raster +resampling during zoom for fewer CPU paints and GPU uploads. Compass ranges also work without opening the Mission Map. Their radii are 1012, 1248, 2512, and 3500 Guild Wars units, projected from the certified diff --git a/src/main/certification/native-compass-terrain-transform.ts b/src/main/certification/native-compass-terrain-transform.ts new file mode 100644 index 00000000..e463d353 --- /dev/null +++ b/src/main/certification/native-compass-terrain-transform.ts @@ -0,0 +1,228 @@ +/** + * Gives the exact native Compass Canvas one retained terrain texture and mesh. + * Native frame attachment, resource references and camera projection own drawing. + */ +import { + concat, encodeCode, encodeIndexVector, encodeSection, parseCode, parseExports, + parseIndexVector, sectionById, sleb, splitSections, uleb, vectorPayload, WASM_HEADER, +} from "../core/wasm-binary.js"; +import { encodeName } from "./cartography-transform-internals.js"; +import { functionBodySha256, wasmEvidence } from "./wasm-evidence.js"; +import { + NATIVE_COMPASS_CLIP_RADIUS, NATIVE_COMPASS_TERRAIN_HEADER_BYTES, + NATIVE_COMPASS_TERRAIN_MAGIC, NATIVE_COMPASS_TERRAIN_MAX_SIZE, + NATIVE_COMPASS_TERRAIN_WORLD_SPAN, NATIVE_COMPASS_WORLD_SCALE, +} from "../../shared/native-compass-terrain.js"; + +const CHECKED = [ + [14112, "40b8dfac2eb2e4c861e2daa4d3e775de33d15194c64765ddb53025bcb73fb74f"], + [14116, "ee5ae8fd4eecae9bccac822d7ae7f3614286a2c395af6b348f6b2082c6c23aee"], + [14126, "256397ed0536d44ea656323cddb51bc29fb2b87b6af98a5793ff8ee9292a7c6a"], + [14137, "019d8124a7e7ebcc6691a5d7adc87cef503d3eca5789ab2c6667bce2afad495f"], + [14132, "1f9a028cc7edb1245971483bce34905c5121b85b305a8b3eed34987ca9a8f966"], + [14147, "b47aa2a9b0df83431a59ccf6754b9435af7f2f4b9e35be532930ce89323854ae"], + [1444, "88b977ad123c93c1926a67efe32721398cafb0f86fa2b3f5a57f3bd1493fb842"], + [1445, "a883926f470e716e59f62bec0b03adc8721112e78e859477bcab028de2ab311b"], + [1446, "3cfe5e2ff85ca8c472ad41c7a3ce4db7eb66c4dcb86b6fdfc69530067fecb650"], + [1448, "79c87cd0bab2c93f9718d557a7bd552ee2c37a70ec7f6d0f82224aa8c45e438d"], + [1449, "3e750376bbc2a51393c0ba169355550ba48a1b7fdea3fff8e1d0b1b5be2d6581"], + [1556, "25b4a1cbaf249c4dab4842fd7c90ef0e2f7d28a9184c81c1167ced06d1ee45ca"], + [1522, "75b99afc7b3b9e6208aba50ea70618e84b3171a550788211e2587c551ab2f041"], + [1565, "c7e2cc16116757011dfecf14eff0220469221cb200b109b650461eb5c750ceba"], + [1530, "e890ea8a31e26749317404120c8d07354743fcd31a603921416875723683e783"], + [1569, "46b25139552a8d66a0b519d735755346f9be5d37ab2d1e9b02bca1d79bee20fc"], + [1532, "f77af25d4e59724cd0ed6103fb9fcbe92b035e09ee24456f00fafdc44bfc286a"], + [1579, "c46a846084eb5292db3d41d901724d9e2330cf1bb9f7fb1dec371fa642e4cc48"], + [3137, "3f4d7e18ad91c20147e2053ee1e42f8eb60c97937fefba142d37c93f2d672e36"], + [2249, "149d95966e67f554da12f46c064ee366a9d531b167842e6ea0a201ca730b474e"], + [2187, "fe75a199add4d8ffb71a03e694144b903e3e82a7c165558b7593a8e5f9495415"], + [748, "150d8921520d98fbfa28dc4faf5e2fe488a65c69832b13d7dcf489b2f07d7b60"], + [6827, "f8adede1f8a366bdce292461977643bb760e8e846bc30689312781dcab0630c7"], +] as const; +const SEGMENTS = 64; +const op = (...bytes: number[]) => Uint8Array.of(...bytes); +const i = (value: number) => concat(op(0x41), sleb(value)); +const l = (index: number) => concat(op(0x20), uleb(index)); +const s = (index: number) => concat(op(0x21), uleb(index)); +const call = (index: number) => concat(op(0x10), uleb(index)); +const f = (value: number) => { + const bytes = new Uint8Array(5); bytes[0] = 0x43; + new DataView(bytes.buffer).setFloat32(1, value, true); return bytes; +}; +const load = (offset: number, float = false) => concat(op(float ? 0x2a : 0x28, 2), uleb(offset)); +const save = (offset: number, float = false) => concat(op(float ? 0x38 : 0x36, 2), uleb(offset)); +const stack = (index: number, bytes: number) => concat(op(0x23, 0), i(bytes), op(0x6b), s(index), l(index), op(0x24, 0)); +const unstack = (index: number, bytes: number) => concat(l(index), i(bytes), op(0x6a, 0x24, 0)); + +export function appendNativeCompassTerrain(input: Uint8Array): Uint8Array { + const evidence = wasmEvidence(input); + if (!evidence) throw new Error("invalid native Compass terrain input"); + const module = evidence.moduleView(); + if (CHECKED.some(([index, hash]) => functionBodySha256(module, index) !== hash)) return input; + return appendCompassSurface(appendCompassSurface(input, false), true); +} + +/** Both Compass consumers share exact Canvas lifetime and bitmap ownership. */ +function appendCompassSurface(input: Uint8Array, screenSpace: boolean): Uint8Array { + const evidence = wasmEvidence(input); + if (!evidence) throw new Error("invalid Compass surface input"); + const module = evidence.moduleView(); + const prefix = screenSpace ? "gwonmac_compass_ranges" : "gwonmac_compass_terrain"; + const sections = splitSections(input); + const globals = vectorPayload(sectionById(sections, 6)); + const exported = parseExports(sectionById(sections, 7)); + const exportVector = vectorPayload(sectionById(sections, 7)); + const signatures = vectorPayload(sectionById(sections, 1)); + const types = parseIndexVector(sectionById(sections, 3)); + const bodies = parseCode(sectionById(sections, 10)); + const epoch = exported.find((entry) => entry.name === "gwonmac_cartography_context_area_epoch")?.index; + const status = exported.find((entry) => entry.name === "gwonmac_cartography_context_status")?.index; + if (epoch === undefined || status === undefined) return input; + const base = globals.count; + const g = (index: number) => concat(op(0x23), uleb(base + index)); + const put = (index: number) => concat(op(0x24), uleb(base + index)); + const first = module.functionImportCount + bodies.length; + const hideIndex = first; const destroyIndex = first + 1; const initIndex = first + 2; + const attachIndex = first + 3; const updateIndex = first + 4; const publishIndex = first + 5; + const increment = (index: number) => concat(g(index), i(1), op(0x6a), put(index)); + const contextValid = () => concat(g(3), i(0), op(0x4a), g(3), op(0x23), uleb(epoch), op(0x46, 0x71), op(0x23), uleb(status), i(1), op(0x46, 0x71)); + const finite = (value: Uint8Array, limit: number) => concat(value, op(0x8b), f(limit), op(0x5f)); + const hide = concat(op(0), g(3), op(0x04, 0x40), i(0), put(3), + g(1), op(0x04, 0x40), g(1), i(0), call(1445), op(0x1a), g(1), call(1448), op(0x0b, 0x0b, 0x0b)); + const destroy = concat(op(0), l(0), g(0), op(0x46), g(0), i(0), op(0x47, 0x71, 0x04, 0x40), + call(hideIndex), g(2), call(748), g(1), call(748), + ...[0, 1, 2, 3, 4, 9].map((index) => concat(i(0), put(index))), increment(8), op(0x0b, 0x0b)); + // Clone only this Canvas's retained draw object, then replace its one mesh. + // The constructor hook runs after the native transform has been established. + const init = concat(op(0), g(0), call(destroyIndex), l(0), put(0), + i(265), i(0), call(1444), put(1), l(0), load(144), call(1556), put(2), + g(2), i(0), g(1), call(1565), g(2), i(0), call(1579), increment(7), op(0x0b)); + const attach = concat(op(1, 1, 0x7f), + l(0), g(0), op(0x46), g(2), i(0), op(0x47, 0x71, 0x04, 0x40), stack(2, 16), + l(2), g(2), save(0), l(1), i(1), l(2), i(4), call(6827), unstack(2, 16), op(0x0b, 0x0b)); + + const vertexStores: Uint8Array[] = []; + const points: Readonly<{ x: number; y: number; edge: boolean }>[] = [{x: 0, y: 0, edge: false}]; + for (const edge of [false, true]) for (let n = 0; n < SEGMENTS; n += 1) { + const angle = n * 2 * Math.PI / SEGMENTS; + points.push({x: Math.cos(angle), y: Math.sin(angle), edge}); + } + points.forEach((point, vertex) => { + const offset = vertex * 24; + const radius = (dimension: number) => point.edge || vertex === 0 ? f(NATIVE_COMPASS_CLIP_RADIUS) + : concat(f(NATIVE_COMPASS_CLIP_RADIUS), f(1), l(dimension), op(0x95, 0x93)); + vertexStores.push( + f(0.5), f(point.x), radius(6), op(0x94, 0x92), s(12), + f(0.5), f(point.y), radius(7), op(0x94, 0x92), s(13), + // Invert the Canvas's rotation for texture coordinates. The frame itself + // applies the retained transform, so geometry remains Canvas-local. + l(9), l(12), f(0.5), op(0x93, 0x94), l(8), l(13), f(0.5), op(0x93, 0x94, 0x92), f(NATIVE_COMPASS_WORLD_SCALE), op(0x95), s(14), + l(9), l(13), f(0.5), op(0x93, 0x94), l(8), l(12), f(0.5), op(0x93, 0x94, 0x93), f(NATIVE_COMPASS_WORLD_SCALE), op(0x95), s(15), + l(4), l(6), l(12), op(0x94), save(offset, true), + l(4), l(7), l(13), op(0x94), save(offset + 4, true), + l(4), f(0), save(offset + 8, true), + l(4), i(point.edge ? 0x00ffffff : -1), save(offset + 12), + l(4), ...(screenSpace ? [l(12)] : [l(10), l(14), op(0x92), g(10), op(0x93), g(12), op(0x95)]), save(offset + 16, true), + l(4), ...(screenSpace ? [l(13)] : [g(11), l(11), l(15), op(0x92, 0x93), g(12), op(0x95)]), save(offset + 20, true)); + }); + const indexStores: Uint8Array[] = []; + for (let n = 0; n < SEGMENTS; n += 1) { + const a = 1 + n; const b = 1 + (n + 1) % SEGMENTS; + // Clockwise winding matches the native Canvas triangles. + [0, b, a, a, b, a + SEGMENTS, a + SEGMENTS, b, b + SEGMENTS].forEach((vertex, at) => { + indexStores.push(concat(l(5), i(vertex), op(0x3b, 1), uleb((n * 9 + at) * 2))); + }); + } + const update = concat(op(2, 3, 0x7f, 10, 0x7d), + l(0), g(0), op(0x47, 0x04, 0x40, 0x0f, 0x0b), + l(0), load(180, true), l(0), load(172, true), op(0x93), s(6), + l(0), load(184, true), l(0), load(176, true), op(0x93), s(7), + l(2), load(0, true), s(8), l(2), load(4, true), s(9), + l(1), load(0, true), s(10), l(1), load(4, true), s(11), + // An unchanged native camera and rectangle need no buffer rewrite. The + // serial invalidates UVs after a tile upload or indices after withdrawal. + contextValid(), g(4), g(19), op(0x46, 0x71), + l(6), g(15), op(0x5b, 0x71), l(7), g(16), op(0x5b, 0x71), + ...(screenSpace ? [] : [l(10), g(13), op(0x5b, 0x71), l(11), g(14), op(0x5b, 0x71), + l(8), g(17), op(0x5b, 0x71), l(9), g(18), op(0x5b, 0x71)]), + op(0x04, 0x40, 0x0f, 0x0b), + l(8), put(17), l(9), put(18), + l(10), put(13), l(11), put(14), l(6), put(15), l(7), put(16), + contextValid(), finite(l(6), 16384), op(0x71), l(6), f(2), op(0x5e, 0x71), + finite(l(7), 16384), op(0x71), l(7), f(2), op(0x5e, 0x71), + finite(l(10), 1000000), op(0x71), finite(l(11), 1000000), op(0x71), + l(8), l(8), op(0x94), l(9), l(9), op(0x94, 0x92), f(1), op(0x93, 0x8b), f(0.02), op(0x5f, 0x71), + ...(screenSpace ? [] : [ + l(10), g(10), f(4500), op(0x92, 0x60, 0x71), l(10), g(10), g(12), op(0x92), f(4500), op(0x93, 0x5f, 0x71), + l(11), g(11), f(4500), op(0x93, 0x5f, 0x71), l(11), g(11), g(12), op(0x93), f(4500), op(0x92, 0x60, 0x71), + ]), + op(0x45, 0x04, 0x40), call(hideIndex), op(0x0f, 0x0b), + stack(3, 32), g(1), i(points.length), call(1446), s(4), g(1), i(SEGMENTS * 9), call(1445), s(5), + ...vertexStores, ...indexStores, + ...[0, 4, 8].map((offset) => concat(l(3), f(0), save(offset, true))), + ...[12, 16, 20].map((offset) => concat(l(3), l(6), l(7), op(0x97), save(offset, true))), + g(1), l(3), l(3), i(12), op(0x6a), call(1449), g(1), call(1448), + g(4), put(19), increment(5), unstack(3, 32), op(0x0b)); + + // A copied bitmap is the only write capability. Validate its complete region + // before reading pixels; GrTex copies the input with flags & 1 == 0. + const memoryEnd = (bytes: Uint8Array) => concat(l(0), op(0xad), bytes, op(0xad, 0x7c), op(0x3f, 0, 0xad), op(0x42), sleb(65536), op(0x7e, 0x58)); + const publish = concat(op(1, 5, 0x7f), call(hideIndex), + l(0), i(0), op(0x4b), l(0), i(3), op(0x71, 0x45, 0x71), + l(1), i(NATIVE_COMPASS_TERRAIN_HEADER_BYTES), op(0x4f, 0x71), memoryEnd(i(NATIVE_COMPASS_TERRAIN_HEADER_BYTES)), op(0x71), + op(0x45, 0x04, 0x40), i(0), op(0x0f, 0x0b), + l(0), load(12), s(5), l(0), load(8), s(6), + l(0), load(0), i(NATIVE_COMPASS_TERRAIN_MAGIC), op(0x46), l(0), load(4), l(1), op(0x46, 0x71), + l(5), i(64), op(0x4f, 0x71), l(5), i(NATIVE_COMPASS_TERRAIN_MAX_SIZE), op(0x4d, 0x71), + l(5), l(5), i(1), op(0x6b, 0x71, 0x45, 0x71), + l(1), l(5), l(5), op(0x6c), i(4), op(0x6c), i(NATIVE_COMPASS_TERRAIN_HEADER_BYTES), op(0x6a, 0x46, 0x71), + memoryEnd(l(1)), op(0x71), l(6), i(0), op(0x4a, 0x71), l(6), op(0x23), uleb(epoch), op(0x46, 0x71), + op(0x23), uleb(status), i(1), op(0x46, 0x71), g(2), i(0), op(0x47, 0x71), + finite(concat(l(0), load(16, true)), 1000000), op(0x71), finite(concat(l(0), load(20, true)), 1000000), op(0x71), + l(0), load(24, true), f(NATIVE_COMPASS_TERRAIN_WORLD_SPAN), op(0x5b, 0x71), + l(0), load(28), i(0), op(0x4a, 0x71), + op(0x45, 0x04, 0x40), i(0), op(0x0f, 0x0b), stack(4, 32), + l(4), l(0), i(NATIVE_COMPASS_TERRAIN_HEADER_BYTES), op(0x6a), save(0), + l(4), l(5), save(8), l(4), l(5), save(12), + l(4), i(0), l(4), i(8), op(0x6a), i(1), i(112), call(2249), s(2), + l(4), l(2), save(16), l(4), i(screenSpace ? 4 : 7), save(20), l(4), i(482), save(24), + i(1), l(4), i(16), op(0x6a), l(4), i(20), op(0x6a), l(4), i(24), op(0x6a), i(0), i(0), i(33555424), i(screenSpace ? 4 : 11), call(3137), s(3), + g(2), i(0), l(3), call(1569), l(3), call(748), l(2), call(748), + l(0), load(16, true), put(10), l(0), load(20, true), put(11), l(0), load(24, true), put(12), + l(5), put(9), l(0), load(28), put(4), l(6), put(3), increment(6), unstack(4, 32), i(1), op(0x0b)); + + for (const [index, offset, bytes] of [ + [14112, 530, concat(l(0), call(initIndex))], + [14116, 3, concat(l(0), call(destroyIndex))], + [14126, 3, concat(l(0), l(1), call(attachIndex))], + [14137, 17, concat(l(0), l(1), l(2), call(updateIndex))], + ] as const) { + const local = index - module.functionImportCount; const body = bodies[local]!; + let insertion = offset; + if (screenSpace && index === 14126) { + const terrainPublish = exported.find((entry) => entry.name === "gwonmac_compass_terrain_publish")?.index; + if (terrainPublish === undefined) throw new Error("missing native terrain attachment"); + const earlier = concat(l(0), l(1), call(terrainPublish - 2)); + if (!earlier.every((byte, at) => body[offset + at] === byte)) throw new Error("native Compass attachment changed"); + insertion += earlier.length; + } + bodies[local] = concat(body.slice(0, insertion), bytes, body.slice(insertion)); + } + const extraTypes = [ + op(0x60, 0, 0), op(0x60, 1, 0x7f, 0), op(0x60, 2, 0x7f, 0x7f, 0), + op(0x60, 3, 0x7f, 0x7f, 0x7f, 0), op(0x60, 2, 0x7f, 0x7f, 1, 0x7f), + ]; + bodies.push(hide, destroy, init, attach, update, publish); + const globalTypes = [...Array.from({length: 10}, () => 0x7f), ...Array.from({length: 9}, () => 0x7d), 0x7f]; + const scalarExports = [["area", 3], ["serial", 4], ["updates", 5], ["uploads", 6], ["created", 7], ["destroyed", 8], ["size", 9], ["camera_x", 13], ["camera_y", 14], ["width", 15], ["height", 16]] as const; + return concat(WASM_HEADER, ...sections.map((section) => encodeSection({id: section.id, + body: section.id === 1 ? concat(uleb(signatures.count + extraTypes.length), signatures.entries, ...extraTypes) + : section.id === 3 ? encodeIndexVector([...types, ...[0, 1, 1, 2, 3, 4].map((type) => signatures.count + type)]) + : section.id === 6 ? concat(uleb(base + globalTypes.length), globals.entries, ...globalTypes.map((type) => concat(op(type, 1), type === 0x7f ? i(0) : f(0), op(0x0b)))) + : section.id === 7 ? concat(uleb(exportVector.count + scalarExports.length + 2), exportVector.entries, + encodeName(`${prefix}_publish`), op(0), uleb(publishIndex), + encodeName(`${prefix}_hide`), op(0), uleb(hideIndex), + ...scalarExports.map(([name, index]) => concat(encodeName(`${prefix}_${name}`), op(3), uleb(base + index)))) + : section.id === 10 ? encodeCode(bodies) : section.body, + }))); +} diff --git a/src/main/certification/native-map-graphics-transform.ts b/src/main/certification/native-map-graphics-transform.ts new file mode 100644 index 00000000..b870e74f --- /dev/null +++ b/src/main/certification/native-map-graphics-transform.ts @@ -0,0 +1,230 @@ +/** + * Owns bounded Cartography textures drawn by the native Mission and World Map. + * Exact draw events retain the game's clipping, ordering and graphics context. + */ +import { + concat, encodeCode, encodeIndexVector, encodeSection, parseCode, parseExports, + parseIndexVector, sectionById, sleb, splitSections, uleb, vectorPayload, WASM_HEADER, +} from "../core/wasm-binary.js"; +import { encodeName } from "./cartography-transform-internals.js"; +import { functionBodySha256, wasmEvidence } from "./wasm-evidence.js"; +import { NATIVE_MAP_GRAPHICS_HEADER_BYTES, NATIVE_MAP_GRAPHICS_MAGIC, + NATIVE_MAP_GRAPHICS_MAX_SIZE, NATIVE_MAP_GRAPHICS_SURFACES } from "../../shared/native-map-graphics.js"; + +const CHECKED = [ + [ + 16136, + "34712a34dc14537a423cedb253e07f1ed042edc3a989937bf51d47ca5e69e08f" + ], + [ + 16125, + "59c2c2556eeb11939ddfca127f48e9147c954fda6644dfc100bcacbbbb78561c" + ], + [ + 16224, + "7fd419cd211b2d8cc343e626d799a9de508ad8120d700ccc6a7cf63d051501e6" + ], + [ + 16170, + "563f4f1eecdf6b1909dd4adf3bf2f5c48ad29d91ae8f188bba0f2e43b725b897" + ], + [ + 1444, + "88b977ad123c93c1926a67efe32721398cafb0f86fa2b3f5a57f3bd1493fb842" + ], + [ + 1445, + "a883926f470e716e59f62bec0b03adc8721112e78e859477bcab028de2ab311b" + ], + [ + 1446, + "3cfe5e2ff85ca8c472ad41c7a3ce4db7eb66c4dcb86b6fdfc69530067fecb650" + ], + [ + 1448, + "79c87cd0bab2c93f9718d557a7bd552ee2c37a70ec7f6d0f82224aa8c45e438d" + ], + [ + 1449, + "3e750376bbc2a51393c0ba169355550ba48a1b7fdea3fff8e1d0b1b5be2d6581" + ], + [ + 1554, + "6a1b82f3d27e1077795392f733b2acd6b34f8be321dbc7ed65a7af8c07e80ac5" + ], + [ + 1564, + "2ec191d64613c3e4b68f69b0975c716bb4685d4d71dff1a0b1c423d3cc5b9bde" + ], + [ + 1569, + "46b25139552a8d66a0b519d735755346f9be5d37ab2d1e9b02bca1d79bee20fc" + ], + [ + 1579, + "c46a846084eb5292db3d41d901724d9e2330cf1bb9f7fb1dec371fa642e4cc48" + ], + [ + 1333, + "8799e3b4032b03c0c01c88236d3283d17c362ce3b300edf58bce919395fba064" + ], + [ + 1334, + "d0061147c7a6987af15a15663703b726f9bbc015fb5f3e6fa44b28df41dc1905" + ], + [ + 1357, + "ac2e8c49bb67981447c9f66c39b364f90c292c86efa3fe98456743608453f4f5" + ], + [ + 264, + "0265ff024218606f270c9dcca6166d3a5d4bc28809071377662ab1ce0f13e562" + ], + [ + 2956, + "f0368e490c0c24a79a9c667c1e815b4a66f28b495af15efe37c202aefb9406d9" + ], + [ + 2249, + "149d95966e67f554da12f46c064ee366a9d531b167842e6ea0a201ca730b474e" + ], + [ + 3137, + "3f4d7e18ad91c20147e2053ee1e42f8eb60c97937fefba142d37c93f2d672e36" + ], + [ + 748, + "150d8921520d98fbfa28dc4faf5e2fe488a65c69832b13d7dcf489b2f07d7b60" + ] +] as const; +const op = (...bytes: number[]) => Uint8Array.of(...bytes); +const i = (value: number) => concat(op(0x41), sleb(value)); +const l = (index: number) => concat(op(0x20), uleb(index)); +const s = (index: number) => concat(op(0x21), uleb(index)); +const call = (index: number) => concat(op(0x10), uleb(index)); +const f = (value: number) => { + const bytes = new Uint8Array(5); bytes[0] = 0x43; + new DataView(bytes.buffer).setFloat32(1, value, true); return bytes; +}; +const load = (offset: number, float = false) => concat(op(float ? 0x2a : 0x28, 2), uleb(offset)); +const save = (offset: number, float = false) => concat(op(float ? 0x38 : 0x36, 2), uleb(offset)); +const stack = (index: number, bytes: number) => concat(op(0x23, 0), i(bytes), op(0x6b), s(index), l(index), op(0x24, 0)); +const unstack = (index: number, bytes: number) => concat(l(index), i(bytes), op(0x6a, 0x24, 0)); + +export function appendNativeMapGraphics(input: Uint8Array): Uint8Array { + const evidence = wasmEvidence(input); + if (!evidence) throw new Error("invalid native map graphics input"); + const module = evidence.moduleView(); + if (CHECKED.some(([index, hash]) => functionBodySha256(module, index) !== hash)) return input; + const sections = splitSections(input); + const globals = vectorPayload(sectionById(sections, 6)); + const exported = parseExports(sectionById(sections, 7)); + const exportVector = vectorPayload(sectionById(sections, 7)); + const signatures = vectorPayload(sectionById(sections, 1)); + const types = parseIndexVector(sectionById(sections, 3)); + const bodies = parseCode(sectionById(sections, 10)); + const epoch = exported.find((entry) => entry.name === "gwonmac_cartography_context_area_epoch")?.index; + const status = exported.find((entry) => entry.name === "gwonmac_cartography_context_status")?.index; + if (epoch === undefined || status === undefined) return input; + const extraBodies: Uint8Array[] = []; const extraExports: Uint8Array[] = []; + const scalarNames = ["owner", "buffer", "draw", "area", "serial", "uploads", "draws", "created", "destroyed", "continent"]; + const first = module.functionImportCount + bodies.length; + for (const [surfaceIndex, surface] of NATIVE_MAP_GRAPHICS_SURFACES.entries()) { + const base = globals.count + surfaceIndex * scalarNames.length; + const isWorld = surface.startsWith("world"); + const isHover = surface.endsWith("_hover"); + const g = (index: number) => concat(op(0x23), uleb(base + index)); + const put = (index: number) => concat(op(0x24), uleb(base + index)); + const increment = (index: number) => concat(g(index), i(1), op(0x6a), put(index)); + const hideIndex = first + extraBodies.length; const destroyIndex = hideIndex + 1; + const renderIndex = hideIndex + 2; const publishIndex = hideIndex + 3; + const hide = concat(op(0), i(0), put(3), i(0), put(4), op(0x0b)); + const destroy = concat(op(0), l(0), g(0), op(0x46, 0x04, 0x40), call(hideIndex), + g(2), op(0x04, 0x40), g(2), call(748), g(1), call(748), increment(8), op(0x0b), + ...[0, 1, 2].map((index) => concat(i(0), put(index))), op(0x0b, 0x0b)); + // The draw event provides trusted ownership. Native matrices 0/1 already + // project world-map units; only our model matrix is identity, then restored. + const render = concat(op(1, 1, 0x7f), + l(0), g(0), op(0x47, 0x04, 0x40), g(0), call(destroyIndex), l(0), put(0), op(0x0b), + ...(isWorld ? [l(0), load(4), g(9), op(0x47, 0x04, 0x40), call(hideIndex), op(0x0b), l(0), load(4), put(9)] : []), + g(3), i(0), op(0x4a), g(3), op(0x23), uleb(epoch), op(0x46, 0x71), + op(0x23), uleb(status), i(1), op(0x46, 0x71), g(2), i(0), op(0x47, 0x71), + op(0x45, 0x04, 0x40), call(hideIndex), op(0x0f, 0x0b), + stack(1, 80), l(1), i(2), call(1333), i(52), call(264), + i(2), call(1357), g(2), i(0), call(1579), + l(1), g(2), save(64), i(1), l(1), i(64), op(0x6a), call(1564), + i(0), i(1), l(1), i(64), op(0x6a), i(0), call(2956), + i(2), l(1), call(1334), increment(6), unstack(1, 80), op(0x0b)); + const memoryEnd = (bytes: Uint8Array) => concat(l(0), op(0xad), bytes, op(0xad, 0x7c), + op(0x3f, 0, 0xad), op(0x42), sleb(65536), op(0x7e, 0x58)); + const finite = (offset: number) => concat(l(0), load(offset, true), op(0x8b), f(1000000), op(0x5f)); + const dimension = (local: number) => concat(l(local), i(64), op(0x4f), l(local), i(NATIVE_MAP_GRAPHICS_MAX_SIZE), op(0x4d, 0x71), + l(local), l(local), i(1), op(0x6b, 0x71, 0x45, 0x71)); + // Native bitmap layers (14205) use alpha stage 7 / translucent order 11. + // The Compass ring's additive stage 4 cannot darken the terrain outside. + const publish = concat(op(1, 7, 0x7f), call(hideIndex), + l(0), i(0), op(0x4b), l(0), i(3), op(0x71, 0x45, 0x71), + l(1), i(NATIVE_MAP_GRAPHICS_HEADER_BYTES), op(0x4f, 0x71), memoryEnd(i(NATIVE_MAP_GRAPHICS_HEADER_BYTES)), op(0x71), + op(0x45, 0x04, 0x40), i(0), op(0x0f, 0x0b), + l(0), load(12), s(5), l(0), load(16), s(6), l(0), load(8), s(7), + l(0), load(0), i(NATIVE_MAP_GRAPHICS_MAGIC), op(0x46), l(0), load(4), l(1), op(0x46, 0x71), + dimension(5), op(0x71), dimension(6), op(0x71), + l(1), l(5), l(6), op(0x6c), i(4), op(0x6c), i(NATIVE_MAP_GRAPHICS_HEADER_BYTES), op(0x6a, 0x46, 0x71), + memoryEnd(l(1)), op(0x71), l(7), i(0), op(0x4a, 0x71), l(7), op(0x23), uleb(epoch), op(0x46, 0x71), + op(0x23), uleb(status), i(1), op(0x46, 0x71), g(0), i(0), op(0x47, 0x71), + l(0), load(20), i(0), op(0x4a, 0x71), + ...(isWorld ? [l(0), load(24), g(9), op(0x46, 0x71)] : []), + ...Array.from({length: 8}, (_, index) => concat(finite(32 + index * 4), op(0x71))), + op(0x45, 0x04, 0x40), i(0), op(0x0f, 0x0b), stack(4, 64), + l(4), l(0), i(NATIVE_MAP_GRAPHICS_HEADER_BYTES), op(0x6a), save(0), + l(4), l(5), save(8), l(4), l(6), save(12), + l(4), i(0), l(4), i(8), op(0x6a), i(1), i(112), call(2249), s(2), + l(4), l(2), save(16), l(4), i(7), save(20), l(4), i(482), save(24), + i(1), l(4), i(16), op(0x6a), l(4), i(20), op(0x6a), l(4), i(24), op(0x6a), i(0), i(0), i(33555424), i(11), call(3137), s(3), + g(2), op(0x45, 0x04, 0x40), + i(265), i(0), call(1444), put(1), l(4), g(1), save(28), l(4), l(3), save(32), + i(1), l(4), i(28), op(0x6a), l(4), i(32), op(0x6a), i(0), i(0), call(1554), put(2), increment(7), + op(0x05), g(2), i(0), l(3), call(1569), op(0x0b), + l(3), call(748), l(2), call(748), + g(1), i(4), call(1446), s(8), + ...Array.from({length: 4}, (_, vertex) => concat( + l(8), l(0), load(32 + vertex * 8, true), save(vertex * 24, true), + l(8), l(0), load(36 + vertex * 8, true), op(0x8c), save(vertex * 24 + 4, true), + l(8), f(0), save(vertex * 24 + 8, true), l(8), i(-1), save(vertex * 24 + 12), + l(8), f(vertex === 1 || vertex === 2 ? 1 : 0), save(vertex * 24 + 16, true), + l(8), f(vertex >= 2 ? 1 : 0), save(vertex * 24 + 20, true))), + g(1), i(6), call(1445), s(8), + ...[0, 1, 2, 0, 2, 3].map((vertex, index) => concat(l(8), i(vertex), op(0x3b, 1), uleb(index * 2))), + ...[36, 40, 44].map((offset) => concat(l(4), f(-1000000), save(offset, true))), + ...[48, 52, 56].map((offset) => concat(l(4), f(1000000), save(offset, true))), + g(1), l(4), i(36), op(0x6a), l(4), i(48), op(0x6a), call(1449), g(1), call(1448), + l(7), put(3), l(0), load(20), put(4), increment(5), unstack(4, 64), i(1), op(0x0b)); + extraBodies.push(hide, destroy, render, publish); + const hooks = !isWorld + ? [[16136, 939, concat(l(0), call(renderIndex))], [16125, 3, concat(l(0), call(destroyIndex))]] as const + : [[16224, 830, concat(l(4), load(0), call(renderIndex))], [16170, 3, concat(l(0), call(destroyIndex))]] as const; + for (const [index, originalOffset, bytes] of hooks) { + let offset = originalOffset; + if (isHover && (index === 16136 || index === 16224)) { + const earlierRender = first + (isWorld ? 4 : 0) + 2; + offset += (isWorld ? concat(l(4), load(0), call(earlierRender)) : concat(l(0), call(earlierRender))).length; + } + const local = index - module.functionImportCount; const body = bodies[local]!; + bodies[local] = concat(body.slice(0, offset), bytes, body.slice(offset)); + } + for (const [name, index] of [["hide", hideIndex], ["publish", publishIndex]] as const) { + extraExports.push(concat(encodeName(`gwonmac_${surface}_graphics_${name}`), op(0), uleb(index))); + } + for (const index of [3, 4, 5, 6, 7, 8]) { + extraExports.push(concat(encodeName(`gwonmac_${surface}_graphics_${scalarNames[index]}`), op(3), uleb(base + index))); + } + } + const extraTypes = [op(0x60, 0, 0), op(0x60, 1, 0x7f, 0), op(0x60, 2, 0x7f, 0x7f, 1, 0x7f)]; + return concat(WASM_HEADER, ...sections.map((section) => encodeSection({id: section.id, + body: section.id === 1 ? concat(uleb(signatures.count + extraTypes.length), signatures.entries, ...extraTypes) + : section.id === 3 ? encodeIndexVector([...types, ...NATIVE_MAP_GRAPHICS_SURFACES.flatMap(() => [0, 1, 1, 2].map((type) => signatures.count + type))]) + : section.id === 6 ? concat(uleb(globals.count + scalarNames.length * NATIVE_MAP_GRAPHICS_SURFACES.length), globals.entries, ...Array.from({length: scalarNames.length * NATIVE_MAP_GRAPHICS_SURFACES.length}, () => concat(op(0x7f, 1), i(0), op(0x0b)))) + : section.id === 7 ? concat(uleb(exportVector.count + extraExports.length), exportVector.entries, ...extraExports) + : section.id === 10 ? encodeCode([...bodies, ...extraBodies]) : section.body, + }))); +} diff --git a/src/main/certification/pathing-spike-transform.ts b/src/main/certification/pathing-spike-transform.ts index cac296bd..4937f062 100644 --- a/src/main/certification/pathing-spike-transform.ts +++ b/src/main/certification/pathing-spike-transform.ts @@ -38,6 +38,8 @@ import { WASM_HEADER, type Section, } from "../core/wasm-binary.js"; +import { appendNativeMapGraphics } from "./native-map-graphics-transform.js"; +import { appendNativeCompassTerrain } from "./native-compass-terrain-transform.js"; import { functionBodySha256, wasmEvidence } from "./wasm-evidence.js"; import { worldMapDisplayStateAddress } from "./world-map-visibility-proof.js"; import { @@ -75,7 +77,7 @@ declare const WebAssembly: { Module: new (bytes: Uint8Array) => object; }; -export const CARTOGRAPHY_SPIKE_TRANSFORM_ABI = 31; +export const CARTOGRAPHY_SPIKE_TRANSFORM_ABI = 39; export type CartographyMemoryLayoutId = keyof typeof CARTOGRAPHY_MEMORY_LAYOUTS; const mutableI32 = () => Uint8Array.of(0x7f, 0x01, 0x41, 0x00, 0x0b); @@ -474,7 +476,7 @@ export function transformCartographySpikeWasm( if (section.id === 10) return { id: 10, body: encodeCode(nextBodies) }; return section; }); - const output = concat(WASM_HEADER, ...rewritten.map(encodeSection)); + const output = appendNativeMapGraphics(appendNativeCompassTerrain(concat(WASM_HEADER, ...rewritten.map(encodeSection)))); if (!WebAssembly.validate(output)) { try { new WebAssembly.Module(output); diff --git a/src/renderer/cartography-lifecycle.ts b/src/renderer/cartography-lifecycle.ts index 05b01219..46bcda3d 100644 --- a/src/renderer/cartography-lifecycle.ts +++ b/src/renderer/cartography-lifecycle.ts @@ -35,3 +35,14 @@ export function createCartographyLifecycle( }, }; } + + +/** Drain acquired Maps resources even when one native release fails. */ +export function disposeCartographyResources(steps: (() => void)[]): void { + const failures: unknown[] = []; + while (steps.length > 0) { + const step = steps.pop()!; + try { step(); } catch (cause) { failures.push(cause); } + } + if (failures.length > 0) throw new AggregateError(failures, "Maps cleanup failed"); +} diff --git a/src/renderer/cartography-spike/cartography-grid-layer.ts b/src/renderer/cartography-spike/cartography-grid-layer.ts index d211db47..6512b5e1 100644 --- a/src/renderer/cartography-spike/cartography-grid-layer.ts +++ b/src/renderer/cartography-spike/cartography-grid-layer.ts @@ -105,6 +105,7 @@ export type CartographyGridLayer = Readonly<{ hoveredCell: CartographyCell | null; revealRadius: CartographyRevealRadius; }>): void; + image(): Readonly<{ canvas: HTMLCanvasElement; version: string }> | null; snapshot(): CartographyGridLayerSnapshot | null; hide(): void; dispose(): void; @@ -252,26 +253,14 @@ function projectionFingerprint(projection: CartographyGridProjection): string { ].join(":"); } -export function createCartographyGridLayer(parent: HTMLElement, id: string): CartographyGridLayer { - const document = parent.ownerDocument; - const root = document.createElement("div"); - root.id = id; - root.setAttribute("aria-hidden", "true"); - root.style.cssText = [ - "position:fixed", "z-index:9", "display:none", "overflow:hidden", - "pointer-events:none", "user-select:none", - ].join(";"); +export function createCartographyGridLayer(document: Document, inspectionOnly = false): CartographyGridLayer { const canvas = document.createElement("canvas"); - canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none"; - root.append(canvas); - parent.append(root); const context = canvas.getContext("2d"); let drawingVersion = ""; let drawCount = 0; let latest: CartographyGridLayerSnapshot | null = null; const hide = () => { - root.style.display = "none"; drawingVersion = ""; latest = null; }; @@ -290,7 +279,8 @@ export function createCartographyGridLayer(parent: HTMLElement, id: string): Car revealRadius: CartographyRevealRadius, ): boolean => { if (context === null) return false; - const dpr = document.defaultView?.devicePixelRatio ?? 1; + const dpr = Math.min(document.defaultView?.devicePixelRatio ?? 1, + 2048 / Math.max(projection.box.width, projection.box.height)); const width = Math.max(1, Math.round(projection.box.width * dpr)); const height = Math.max(1, Math.round(projection.box.height * dpr)); sizeCanvas(canvas, width, height); @@ -300,50 +290,24 @@ export function createCartographyGridLayer(parent: HTMLElement, id: string): Car context.scale(dpr, dpr); clip(context, projection); const strength = opacity / 100; - const minX = projection.firstCellX * CARTOGRAPHY_CELL_MAP_UNITS; - const maxX = (projection.lastCellX + 1) * CARTOGRAPHY_CELL_MAP_UNITS; - const minY = projection.firstCellY * CARTOGRAPHY_CELL_MAP_UNITS; - const maxY = (projection.lastCellY + 1) * CARTOGRAPHY_CELL_MAP_UNITS; const focusCell = hoveredCell ?? projection.currentCell; - if (Math.min(cellWidthPixels, cellHeightPixels) >= MIN_GRID_CELL_PIXELS) { - context.beginPath(); - for (let cellX = projection.firstCellX; cellX <= projection.lastCellX + 1; cellX += 1) { - const from = projectedPoint(projection, cellX * CARTOGRAPHY_CELL_MAP_UNITS, minY); - const to = projectedPoint(projection, cellX * CARTOGRAPHY_CELL_MAP_UNITS, maxY); - context.moveTo(from.x, from.y); - context.lineTo(to.x, to.y); - } - for (let cellY = projection.firstCellY; cellY <= projection.lastCellY + 1; cellY += 1) { - const from = projectedPoint(projection, minX, cellY * CARTOGRAPHY_CELL_MAP_UNITS); - const to = projectedPoint(projection, maxX, cellY * CARTOGRAPHY_CELL_MAP_UNITS); - context.moveTo(from.x, from.y); - context.lineTo(to.x, to.y); - } - strokeCasedPath( - context, - style.lattice, - style.casingColor, - strength * (projection.surface === "compass" ? 0.72 : 0.82), - ); - } - - if (revealRadius > 0 && Math.min(cellWidthPixels, cellHeightPixels) >= MIN_GRID_CELL_PIXELS) { + if (hoveredCell !== null && revealRadius > 0 && Math.min(cellWidthPixels, cellHeightPixels) >= MIN_GRID_CELL_PIXELS) { cellRangePolygon(context, projection, focusCell, 1); strokeCasedPath(context, style.normalRange, style.casingColor, Math.min(1, strength * 1.2)); } - if (revealRadius === 3 && Math.min(cellWidthPixels, cellHeightPixels) >= MIN_GRID_CELL_PIXELS) { + if (hoveredCell !== null && revealRadius === 3 && Math.min(cellWidthPixels, cellHeightPixels) >= MIN_GRID_CELL_PIXELS) { cellRangePolygon(context, projection, focusCell, 3); strokeCasedPath(context, style.birdsEyeRange, style.casingColor, Math.min(1, strength * 1.1)); } const visibleCellCount = (projection.lastCellX - projection.firstCellX + 1) * (projection.lastCellY - projection.firstCellY + 1); - if ( - projection.surface !== "compass" - && Math.min(cellWidthPixels, cellHeightPixels) >= UNSEEN_MARKER_CELL_PIXELS - && visibleCellCount <= MAX_MARKED_CELLS - ) { + // Cached tiles include an offscreen margin. When that pushes detailed + // markers over budget, retain guidance with clusters instead of a blank map. + const detailed = visibleCellCount <= MAX_MARKED_CELLS + && (projection.surface === "compass" || Math.min(cellWidthPixels, cellHeightPixels) >= UNSEEN_MARKER_CELL_PIXELS); + if (!inspectionOnly && detailed) { for (let cellY = projection.firstCellY; cellY <= projection.lastCellY; cellY += 1) { for (let cellX = projection.firstCellX; cellX <= projection.lastCellX; cellX += 1) { const explored = isExplored(cellX, cellY); @@ -351,7 +315,7 @@ export function createCartographyGridLayer(parent: HTMLElement, id: string): Car const revealable = explored === null ? null : canCurrentMapReveal(cellX, cellY); - if (remaining === true && revealable !== true) { + if (projection.surface !== "compass" && remaining === true && revealable !== true) { drawUnseenCellMarker( context, "diamond", @@ -382,12 +346,10 @@ export function createCartographyGridLayer(parent: HTMLElement, id: string): Car } if ( - projection.surface !== "compass" - && Math.min(cellWidthPixels, cellHeightPixels) < UNSEEN_MARKER_CELL_PIXELS + !inspectionOnly && projection.surface !== "compass" && !detailed ) { - const groupSize = cartographyProgressClusterSize( - Math.min(cellWidthPixels, cellHeightPixels), - ); + const detailSize = cartographyProgressClusterSize(Math.min(cellWidthPixels, cellHeightPixels)); + const groupSize = detailSize === 16 || visibleCellCount > MAX_MARKED_CELLS * 16 ? 16 : 4; const firstGroup = cartographyProgressClusterOrigin( { x: projection.firstCellX, y: projection.firstCellY }, groupSize, @@ -439,13 +401,6 @@ export function createCartographyGridLayer(parent: HTMLElement, id: string): Car } } - if ( - revealRadius > 0 - && Math.min(cellWidthPixels, cellHeightPixels) >= MIN_GRID_CELL_PIXELS - ) { - polygon(context, cornersForCell(projection, projection.currentCell.x, projection.currentCell.y)); - strokeCasedPath(context, style.current, style.casingColor, Math.min(1, strength * 1.25)); - } if (hoveredCell !== null && Math.min(cellWidthPixels, cellHeightPixels) >= MIN_GRID_CELL_PIXELS) { polygon(context, cornersForCell(projection, hoveredCell.x, hoveredCell.y)); strokeCasedPath(context, style.hover, style.casingColor, Math.min(1, strength * 1.25)); @@ -469,7 +424,7 @@ export function createCartographyGridLayer(parent: HTMLElement, id: string): Car hoveredCell, revealRadius, }) { - const { box, transform } = projection; + const { transform } = projection; const cellWidthPixels = Math.hypot(transform.a, transform.b) * CARTOGRAPHY_CELL_MAP_UNITS; const cellHeightPixels = Math.hypot(transform.c, transform.d) * CARTOGRAPHY_CELL_MAP_UNITS; if ( @@ -481,10 +436,6 @@ export function createCartographyGridLayer(parent: HTMLElement, id: string): Car hide(); return; } - root.style.left = `${box.left}px`; - root.style.top = `${box.top}px`; - root.style.width = `${box.width}px`; - root.style.height = `${box.height}px`; const nextVersion = [ projectionFingerprint(projection), document.defaultView?.devicePixelRatio ?? 1, @@ -512,7 +463,6 @@ export function createCartographyGridLayer(parent: HTMLElement, id: string): Car } } drawingVersion = nextVersion; - root.style.display = "block"; latest = Object.freeze({ surface: projection.surface, currentCellX: projection.currentCell.x, @@ -530,10 +480,12 @@ export function createCartographyGridLayer(parent: HTMLElement, id: string): Car drawCount, }); }, + image: () => latest === null ? null : { canvas, version: drawingVersion }, snapshot: () => latest, hide, dispose() { - root.remove(); + hide(); + canvas.width = 0; canvas.height = 0; latest = null; }, }); diff --git a/src/renderer/cartography-spike/cartography-model.ts b/src/renderer/cartography-spike/cartography-model.ts index 70f31c8f..9f9b1963 100644 --- a/src/renderer/cartography-spike/cartography-model.ts +++ b/src/renderer/cartography-spike/cartography-model.ts @@ -139,7 +139,6 @@ const continentCache = new WeakMap< Readonly<{ continent: number; generation: number; - explorationSequence: number; width: number; height: number; value: Extract; @@ -249,9 +248,10 @@ function deriveContinentCached( if ( cached?.continent === continent && cached.generation === generation - && cached.explorationSequence === explorationSequence && cached.width === width && cached.height === height + && cached.value.explored.words.length === explorationWords.length + && cached.value.explored.words.every((word, index) => word === explorationWords[index]) ) return cached.value; const value = deriveContinent( continent, @@ -265,7 +265,6 @@ function deriveContinentCached( continentCache.set(source, Object.freeze({ continent, generation, - explorationSequence, width, height, value, diff --git a/src/renderer/cartography-spike/compass-range-layer.ts b/src/renderer/cartography-spike/compass-range-layer.ts index c9e09690..495eb7a5 100644 --- a/src/renderer/cartography-spike/compass-range-layer.ts +++ b/src/renderer/cartography-spike/compass-range-layer.ts @@ -46,6 +46,7 @@ export type CompassRangeLayer = Readonly<{ ranges: readonly CompassRangeSelection[], theme: CompassRangeTheme, ): void; + image(): Readonly<{ canvas: HTMLCanvasElement; version: string }> | null; snapshot(): CompassRangeLayerSnapshot; dispose(): void; }>; @@ -123,7 +124,7 @@ export function createCompassRangeLayer(parent: HTMLElement): CompassRangeLayer const tooltip = document.createElement("div"); tooltip.className = "compass-range-tooltip"; tooltip.hidden = true; - root.append(canvas, tooltip); + root.append(tooltip); parent.append(root); const context = canvas.getContext("2d"); let drawingVersion = ""; @@ -143,7 +144,7 @@ export function createCompassRangeLayer(parent: HTMLElement): CompassRangeLayer if (context === null) return false; const dpr = document.defaultView?.devicePixelRatio ?? 1; const cssWidth = Number.parseFloat(root.style.width); - const cssHeight = Number.parseFloat(root.style.height); + const cssHeight = cssWidth; const width = Math.max(1, Math.round(cssWidth * dpr)); const height = Math.max(1, Math.round(cssHeight * dpr)); sizeCanvas(canvas, width, height); @@ -217,10 +218,12 @@ export function createCompassRangeLayer(parent: HTMLElement): CompassRangeLayer root.style.display = "block"; latest = Object.freeze({ status: "visible", drawCount, projection }); }, + image: () => latest.status === "visible" ? { canvas, version: drawingVersion } : null, snapshot: () => latest, dispose() { document.removeEventListener("pointermove", onPointerMove); root.remove(); + canvas.width = 0; canvas.height = 0; hide("disabled"); }, }); diff --git a/src/renderer/cartography-spike/continent-coverage-layer.ts b/src/renderer/cartography-spike/continent-coverage-layer.ts deleted file mode 100644 index 5d2d4b95..00000000 --- a/src/renderer/cartography-spike/continent-coverage-layer.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Owns the soft continent progress tint. It caches one bitmap-space mask and - * projects that mask through a certified native-map transform without walking - * every explored cell on animation frames. - */ -import type { ExploredCreditableBitset } from "./cartography-model.js"; -import { - CARTOGRAPHY_CELL_MAP_UNITS, - type CartographyGridProjection, -} from "./cartography-grid-projection.js"; - -const DEFAULT_OPACITY = 0.16; - -function has(words: Uint32Array, index: number): boolean { - return ((words[index >>> 5]! >>> (index & 31)) & 1) === 1; -} - -function sizeCanvas(canvas: HTMLCanvasElement, width: number, height: number): void { - if (canvas.width !== width) canvas.width = width; - if (canvas.height !== height) canvas.height = height; -} - -function fingerprint(projection: CartographyGridProjection): string { - const { box, transform } = projection; - return [ - projection.surface, - box.left, box.top, box.width, box.height, - transform.a, transform.b, transform.c, transform.d, transform.e, transform.f, - JSON.stringify(projection.clip), - ].join(":"); -} - -function clip(context: CanvasRenderingContext2D, projection: CartographyGridProjection): void { - context.beginPath(); - if (projection.clip.kind === "circle") { - context.arc( - projection.clip.centerX, - projection.clip.centerY, - projection.clip.radius, - 0, - Math.PI * 2, - ); - } else context.rect(0, 0, projection.box.width, projection.box.height); - context.clip(); -} - -export type ContinentCoverageLayer = Readonly<{ - update(input: Readonly<{ - projection: CartographyGridProjection; - explored: ExploredCreditableBitset; - version: string; - color?: string; - opacity?: number; - }>): void; - hide(): void; - dispose(): void; -}>; - -/** Create one pointer-transparent layer for Mission Map or World Map progress. */ -export function createContinentCoverageLayer( - parent: HTMLElement, - id: string, -): ContinentCoverageLayer { - const document = parent.ownerDocument; - const root = document.createElement("div"); - root.id = id; - root.setAttribute("aria-hidden", "true"); - root.style.cssText = [ - "position:fixed", "z-index:8", "display:none", "overflow:hidden", - "pointer-events:none", "user-select:none", - ].join(";"); - const canvas = document.createElement("canvas"); - canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none"; - root.append(canvas); - parent.append(root); - const context = canvas.getContext("2d"); - const mask = document.createElement("canvas"); - const maskContext = mask.getContext("2d"); - let maskVersion = ""; - let drawingVersion = ""; - - const hide = (): void => { - root.style.display = "none"; - drawingVersion = ""; - }; - - return Object.freeze({ - update({ projection, explored, version, color = "#407D5C", opacity = DEFAULT_OPACITY }) { - if (context === null || maskContext === null || opacity <= 0 || opacity > 1) { - hide(); - return; - } - if (version !== maskVersion) { - sizeCanvas(mask, explored.width, explored.height); - const pixels = maskContext.createImageData(explored.width, explored.height); - for (let index = 0; index < explored.width * explored.height; index += 1) { - if (!has(explored.words, index)) continue; - const offset = index * 4; - pixels.data[offset] = 255; - pixels.data[offset + 1] = 255; - pixels.data[offset + 2] = 255; - pixels.data[offset + 3] = 255; - } - maskContext.putImageData(pixels, 0, 0); - maskVersion = version; - } - const dpr = document.defaultView?.devicePixelRatio ?? 1; - const nextDrawingVersion = [fingerprint(projection), version, color, opacity, dpr].join(":"); - if (nextDrawingVersion !== drawingVersion) { - sizeCanvas( - canvas, - Math.max(1, Math.round(projection.box.width * dpr)), - Math.max(1, Math.round(projection.box.height * dpr)), - ); - context.setTransform(1, 0, 0, 1, 0, 0); - context.clearRect(0, 0, canvas.width, canvas.height); - context.save(); - context.scale(dpr, dpr); - clip(context, projection); - context.globalAlpha = opacity; - context.fillStyle = color; - context.imageSmoothingEnabled = false; - const { a, b, c, d, e, f } = projection.transform; - context.setTransform( - a * CARTOGRAPHY_CELL_MAP_UNITS * dpr, - b * CARTOGRAPHY_CELL_MAP_UNITS * dpr, - c * CARTOGRAPHY_CELL_MAP_UNITS * dpr, - d * CARTOGRAPHY_CELL_MAP_UNITS * dpr, - e * dpr, - f * dpr, - ); - context.globalCompositeOperation = "source-over"; - context.drawImage(mask, 0, 0); - context.globalCompositeOperation = "source-in"; - context.globalAlpha = 1; - context.fillRect(0, 0, explored.width, explored.height); - context.restore(); - drawingVersion = nextDrawingVersion; - } - root.style.left = `${projection.box.left}px`; - root.style.top = `${projection.box.top}px`; - root.style.width = `${projection.box.width}px`; - root.style.height = `${projection.box.height}px`; - root.style.display = "block"; - }, - hide, - dispose() { - root.remove(); - }, - }); -} diff --git a/src/renderer/cartography-spike/current-instance-boundary-layer.ts b/src/renderer/cartography-spike/current-instance-boundary-layer.ts deleted file mode 100644 index a32c87f9..00000000 --- a/src/renderer/cartography-spike/current-instance-boundary-layer.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Draws the boundary of the current instance's certified live evidence. The - * boundary explains why solid-orange guidance stops without implying anything - * about surrounding continent candidates. - */ -import { strokeCasedPath } from "./cartography-paint.js"; -import type { CartographyGridProjection } from "./cartography-grid-projection.js"; - -type MapBounds = Readonly<{ - min: Readonly<{ x: number; y: number }>; - max: Readonly<{ x: number; y: number }>; -}>; - -function sizeCanvas(canvas: HTMLCanvasElement, width: number, height: number): void { - if (canvas.width !== width) canvas.width = width; - if (canvas.height !== height) canvas.height = height; -} - -function projectedPoint( - projection: CartographyGridProjection, - mapX: number, - mapY: number, -): Readonly<{ x: number; y: number }> { - const { transform } = projection; - return Object.freeze({ - x: transform.a * mapX + transform.c * mapY + transform.e, - y: transform.b * mapX + transform.d * mapY + transform.f, - }); -} - -function clip(context: CanvasRenderingContext2D, projection: CartographyGridProjection): void { - context.beginPath(); - if (projection.clip.kind === "circle") { - context.arc( - projection.clip.centerX, - projection.clip.centerY, - projection.clip.radius, - 0, - Math.PI * 2, - ); - } else context.rect(0, 0, projection.box.width, projection.box.height); - context.clip(); -} - -export type CurrentInstanceBoundaryLayer = Readonly<{ - update(input: Readonly<{ - projection: CartographyGridProjection; - bounds: MapBounds; - version: string; - }>): void; - hide(): void; - dispose(): void; -}>; - -/** Create one passive boundary layer for a non-Compass map surface. */ -export function createCurrentInstanceBoundaryLayer( - parent: HTMLElement, - id: string, -): CurrentInstanceBoundaryLayer { - const document = parent.ownerDocument; - const root = document.createElement("div"); - root.id = id; - root.setAttribute("aria-hidden", "true"); - root.style.cssText = [ - "position:fixed", "z-index:11", "display:none", "overflow:hidden", - "pointer-events:none", "user-select:none", - ].join(";"); - const canvas = document.createElement("canvas"); - canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none"; - root.append(canvas); - parent.append(root); - const context = canvas.getContext("2d"); - let drawingVersion = ""; - - const hide = (): void => { - root.style.display = "none"; - drawingVersion = ""; - }; - - return Object.freeze({ - update({ projection, bounds, version }) { - if (context === null || projection.surface === "compass") { - hide(); - return; - } - const dpr = document.defaultView?.devicePixelRatio ?? 1; - const { a, b, c, d, e, f } = projection.transform; - const fingerprint = [ - version, - projection.box.width, - projection.box.height, - a, b, c, d, e, f, - bounds.min.x, - bounds.min.y, - bounds.max.x, - bounds.max.y, - dpr, - ].join(":"); - if (fingerprint !== drawingVersion) { - sizeCanvas( - canvas, - Math.max(1, Math.round(projection.box.width * dpr)), - Math.max(1, Math.round(projection.box.height * dpr)), - ); - context.setTransform(1, 0, 0, 1, 0, 0); - context.clearRect(0, 0, canvas.width, canvas.height); - context.save(); - context.scale(dpr, dpr); - clip(context, projection); - const corners = [ - projectedPoint(projection, bounds.min.x, bounds.min.y), - projectedPoint(projection, bounds.max.x, bounds.min.y), - projectedPoint(projection, bounds.max.x, bounds.max.y), - projectedPoint(projection, bounds.min.x, bounds.max.y), - ]; - context.beginPath(); - context.moveTo(corners[0]!.x, corners[0]!.y); - for (const corner of corners.slice(1)) context.lineTo(corner.x, corner.y); - context.closePath(); - strokeCasedPath( - context, - Object.freeze({ color: "#D7D3C7", width: 1.25, pattern: "solid" }), - "#111719", - 0.62, - ); - context.restore(); - drawingVersion = fingerprint; - } - root.style.left = `${projection.box.left}px`; - root.style.top = `${projection.box.top}px`; - root.style.width = `${projection.box.width}px`; - root.style.height = `${projection.box.height}px`; - root.style.display = "block"; - }, - hide, - dispose() { - root.remove(); - }, - }); -} diff --git a/src/renderer/cartography-spike/index.ts b/src/renderer/cartography-spike/index.ts index 0c4a54e6..0c564569 100644 --- a/src/renderer/cartography-spike/index.ts +++ b/src/renderer/cartography-spike/index.ts @@ -2,6 +2,9 @@ * Owns Cartography installation behind one observer, model, kernel, and UI lifecycle. * Partial installation is rolled back before control returns to the harness. */ +import { createNativeCompassRangesLayer } from "./native-compass-ranges-layer.js"; +import { createNativeMapGraphicsLayer } from "./native-map-graphics-layer.js"; +import { createNativeCompassTerrainLayer } from "./native-compass-terrain-layer.js"; import type { CartographyEvidenceCapture, CartographyEvidenceExportResult, @@ -15,6 +18,7 @@ import type { WorldMapFrameSpikeController, WorldMapFrameSpikeDiagnostic, } from "../../shared/cartography-spike.js"; +import { disposeCartographyResources } from "../cartography-lifecycle.js"; import { readCartographyPlayerState } from "../cartography-player-state.js"; import { createCartographyContextReader } from "./context-observer.js"; import { createExplorationSpikeReader } from "./exploration-observer.js"; @@ -109,39 +113,57 @@ export async function installCartographySpike(options: Readonly<{ console.error("[cartography] remembered map knowledge unavailable", cause); } } - if (compassReader !== null) window.gwCompassFrameSpike = compassReader; - if (missionMapReader !== null) window.gwMissionMapFrameSpike = missionMapReader; - if (worldMapReader !== null) window.gwWorldMapFrameSpike = worldMapReader; - window.gwWorldMapAnchorSpike = anchor; - window.gwExplorationSpike = exploration; - const disposeOverlay = mountCartographyOverlay({ - parent: options.parent, - canvas: options.canvas, - modelSources: { - context, - compass, - missionMap, - worldMap, - exploration, - anchor, - kernel, - companion: readCartographyPlayerState, - revealRadius: () => options.settings().cartographyRevealMode === "birds-eye" ? 3 : 1, - }, - settings: options.settings, - persist: options.persist, - exportEvidence: options.exportEvidence, - initialMapKnowledge, - recordMapKnowledge: options.recordMapKnowledge, - }); + const cleanup = [() => kernel.dispose()]; + const dispose = () => disposeCartographyResources(cleanup); + try { + const nativeCompassRanges = createNativeCompassRangesLayer(options.exports, options.parent.ownerDocument); + cleanup.push(() => nativeCompassRanges.dispose()); + const nativeMapGraphics = createNativeMapGraphicsLayer(options.exports, options.parent.ownerDocument); + cleanup.push(() => nativeMapGraphics.dispose()); + const compassTerrainLayer = createNativeCompassTerrainLayer(options.exports, options.parent.ownerDocument); + cleanup.push(() => compassTerrainLayer.dispose()); + if (compassReader !== null) window.gwCompassFrameSpike = compassReader; + if (missionMapReader !== null) window.gwMissionMapFrameSpike = missionMapReader; + if (worldMapReader !== null) window.gwWorldMapFrameSpike = worldMapReader; + window.gwWorldMapAnchorSpike = anchor; + window.gwExplorationSpike = exploration; + cleanup.push(() => { + if (window.gwCompassFrameSpike === compassReader) delete window.gwCompassFrameSpike; + if (window.gwMissionMapFrameSpike === missionMapReader) delete window.gwMissionMapFrameSpike; + if (window.gwWorldMapFrameSpike === worldMapReader) delete window.gwWorldMapFrameSpike; + if (window.gwWorldMapAnchorSpike === anchor) delete window.gwWorldMapAnchorSpike; + if (window.gwExplorationSpike === exploration) delete window.gwExplorationSpike; + }); + const disposeOverlay = mountCartographyOverlay({ + compassTerrainLayer, + nativeMapGraphics, + nativeCompassRanges, + parent: options.parent, + canvas: options.canvas, + modelSources: { + context, + compass, + missionMap, + worldMap, + exploration, + anchor, + kernel, + companion: readCartographyPlayerState, + revealRadius: () => options.settings().cartographyRevealMode === "birds-eye" ? 3 : 1, + }, + settings: options.settings, + persist: options.persist, + exportEvidence: options.exportEvidence, + initialMapKnowledge, + recordMapKnowledge: options.recordMapKnowledge, + }); - return () => { - disposeOverlay(); - kernel.dispose(); - if (window.gwCompassFrameSpike === compassReader) delete window.gwCompassFrameSpike; - if (window.gwMissionMapFrameSpike === missionMapReader) delete window.gwMissionMapFrameSpike; - if (window.gwWorldMapFrameSpike === worldMapReader) delete window.gwWorldMapFrameSpike; - if (window.gwWorldMapAnchorSpike === anchor) delete window.gwWorldMapAnchorSpike; - if (window.gwExplorationSpike === exploration) delete window.gwExplorationSpike; - }; + cleanup.push(disposeOverlay); + return dispose; + } catch (cause) { + try { dispose(); } catch (cleanupError) { + throw new AggregateError([cause, cleanupError], "Maps installation and cleanup failed", {cause: cleanupError}); + } + throw cause; + } } diff --git a/src/renderer/cartography-spike/inverse-mask-layer.ts b/src/renderer/cartography-spike/inverse-mask-layer.ts index fa06b1fc..8820c7d2 100644 --- a/src/renderer/cartography-spike/inverse-mask-layer.ts +++ b/src/renderer/cartography-spike/inverse-mask-layer.ts @@ -1,149 +1,28 @@ /** - * Draws one inverse walkability mask for any certified native map projection. - * Owns clipping, veil removal, boundary drawing, and screen-sized canvas reuse. + * Caches the Mission Map terrain bitmap for native map composition. + * Raster painting is shared with the native Compass texture owner. */ -import type { CartographyWalkabilityStyle } from "../../shared/cartography-overlay.js"; +import { createInverseMaskPainter, type InverseMaskPaint } from "./inverse-mask-painter.js"; import { cartographyWalkabilityStyleFingerprint } from "./cartography-paint.js"; -import type { InverseMaskProjection } from "./map-projections.js"; -import type { WalkableTerrainSurface } from "./walkable-terrain-surface.js"; export type InverseMaskLayer = Readonly<{ - update(input: Readonly<{ - projection: InverseMaskProjection; - terrain: WalkableTerrainSurface; - version: string; - style: CartographyWalkabilityStyle; - opacity: number; - }>): void; + update(input: InverseMaskPaint): void; + image(): Readonly<{ canvas: HTMLCanvasElement; version: string }> | null; hide(): void; dispose(): void; }>; -function clipProjection( - context: CanvasRenderingContext2D, - projection: InverseMaskProjection, -): void { - context.beginPath(); - if (projection.clip.kind === "circle") { - context.arc( - projection.clip.centerX, - projection.clip.centerY, - projection.clip.radius, - 0, - Math.PI * 2, - ); - } else { - context.rect(0, 0, projection.box.width, projection.box.height); - } - context.clip(); -} - -function sizeCanvas(canvas: HTMLCanvasElement, width: number, height: number): void { - if (canvas.width !== width) canvas.width = width; - if (canvas.height !== height) canvas.height = height; -} - -/** One renderer for every native map surface; only the projection changes. */ +/** Detached Mission Map bitmap with content-based invalidation. */ export function createInverseMaskLayer( - parent: HTMLElement, - id: string, + document: Document, ): InverseMaskLayer { - const document = parent.ownerDocument; - const root = document.createElement("div"); - root.id = id; - root.setAttribute("aria-hidden", "true"); - root.style.cssText = [ - "position:fixed", "z-index:8", "display:none", "overflow:hidden", - "pointer-events:none", "user-select:none", - ].join(";"); - const canvas = document.createElement("canvas"); - canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none"; - const context = canvas.getContext("2d"); - const walkable = document.createElement("canvas"); - const expanded = document.createElement("canvas"); - const walkableContext = walkable.getContext("2d"); - const expandedContext = expanded.getContext("2d"); - root.append(canvas); - parent.append(root); + const painter = createInverseMaskPainter(document); let drawingVersion = ""; - const draw = (input: Parameters[0]): boolean => { - if (context === null || walkableContext === null || expandedContext === null) return false; - const dpr = document.defaultView?.devicePixelRatio ?? 1; - const width = Math.max(1, Math.round(input.projection.box.width * dpr)); - const height = Math.max(1, Math.round(input.projection.box.height * dpr)); - for (const target of [canvas, walkable, expanded]) sizeCanvas(target, width, height); - - const { a, b, c, d, e, f } = input.projection.transform; - walkableContext.setTransform(1, 0, 0, 1, 0, 0); - walkableContext.clearRect(0, 0, width, height); - walkableContext.save(); - walkableContext.scale(dpr, dpr); - clipProjection(walkableContext, input.projection); - walkableContext.setTransform(a * dpr, b * dpr, c * dpr, d * dpr, e * dpr, f * dpr); - walkableContext.imageSmoothingEnabled = true; - walkableContext.drawImage(input.terrain.canvas, 0, 0); - walkableContext.restore(); - - context.setTransform(1, 0, 0, 1, 0, 0); - context.clearRect(0, 0, width, height); - context.save(); - context.scale(dpr, dpr); - clipProjection(context, input.projection); - context.globalAlpha = input.opacity / 100; - context.fillStyle = input.style.veilColor; - context.fillRect(0, 0, input.projection.box.width, input.projection.box.height); - context.restore(); - context.globalCompositeOperation = "destination-out"; - context.drawImage(walkable, 0, 0); - context.globalCompositeOperation = "source-over"; - - const outlineRadius = Math.round(input.style.boundaryWidth * dpr); - if (outlineRadius > 0) { - const paintOutline = (radius: number, color: string, alpha: number): void => { - expandedContext.setTransform(1, 0, 0, 1, 0, 0); - expandedContext.clearRect(0, 0, width, height); - expandedContext.globalCompositeOperation = "source-over"; - // One blurred alpha shadow replaces the old radius-squared loop of - // full-surface copies. The core is removed below, leaving only the rim. - expandedContext.shadowColor = color; - expandedContext.shadowBlur = Math.max(1, radius * 1.35); - expandedContext.drawImage(walkable, 0, 0); - expandedContext.shadowColor = "transparent"; - expandedContext.shadowBlur = 0; - expandedContext.globalCompositeOperation = "destination-out"; - expandedContext.drawImage(walkable, 0, 0); - expandedContext.globalCompositeOperation = "source-in"; - expandedContext.fillStyle = color; - expandedContext.fillRect(0, 0, width, height); - expandedContext.globalCompositeOperation = "source-over"; - context.save(); - context.globalAlpha = alpha; - context.drawImage(expanded, 0, 0); - context.restore(); - }; - const strength = input.opacity / 100; - paintOutline( - outlineRadius + Math.max(1, Math.round(dpr)), - input.style.boundaryCasingColor, - Math.min(1, strength * 1.45), - ); - paintOutline( - outlineRadius, - input.style.boundaryColor, - Math.min(1, strength * 1.6), - ); - } - return true; - }; - return Object.freeze({ update(input) { + if (input.opacity <= 0) { drawingVersion = ""; return; } const { box } = input.projection; - root.style.left = `${box.left}px`; - root.style.top = `${box.top}px`; - root.style.width = `${box.width}px`; - root.style.height = `${box.height}px`; const transform = input.projection.transform; const nextVersion = [ input.version, @@ -153,20 +32,23 @@ export function createInverseMaskLayer( JSON.stringify(input.projection.clip), cartographyWalkabilityStyleFingerprint(input.style), input.opacity, ].join(":"); - if (nextVersion !== drawingVersion && !draw(input)) { - root.style.display = "none"; - drawingVersion = ""; - return; + if (nextVersion !== drawingVersion) { + const canvas = painter.draw(input, Math.min(document.defaultView?.devicePixelRatio ?? 1, + 2048 / Math.max(box.width, box.height))); + if (canvas === null) { + drawingVersion = ""; + return; + } } drawingVersion = nextVersion; - root.style.display = input.opacity === 0 ? "none" : "block"; }, + image: () => drawingVersion === "" ? null : { canvas: painter.canvas, version: drawingVersion }, hide() { - root.style.display = "none"; drawingVersion = ""; }, dispose() { - root.remove(); + drawingVersion = ""; + painter.dispose(); }, }); } diff --git a/src/renderer/cartography-spike/inverse-mask-painter.ts b/src/renderer/cartography-spike/inverse-mask-painter.ts new file mode 100644 index 00000000..440d3fc0 --- /dev/null +++ b/src/renderer/cartography-spike/inverse-mask-painter.ts @@ -0,0 +1,118 @@ +/** + * Owns inverse-mask raster painting shared by native Compass textures and Mission Map. + * Surface placement and native texture ownership stay with their respective layers. + */ +import type { CartographyWalkabilityStyle } from "../../shared/cartography-overlay.js"; +import type { InverseMaskProjection } from "./map-projections.js"; +import type { WalkableTerrainSurface } from "./walkable-terrain-surface.js"; + +export type InverseMaskPaint = Readonly<{ + projection: InverseMaskProjection; + terrain: WalkableTerrainSurface; + version: string; + style: CartographyWalkabilityStyle; + opacity: number; +}>; + +function clipProjection( + context: CanvasRenderingContext2D, + projection: InverseMaskProjection, +): void { + context.beginPath(); + if (projection.clip.kind === "circle") { + context.arc( + projection.clip.centerX, + projection.clip.centerY, + projection.clip.radius, + 0, + Math.PI * 2, + ); + } else { + context.rect(0, 0, projection.box.width, projection.box.height); + } + context.clip(); +} + +function sizeCanvas(canvas: HTMLCanvasElement, width: number, height: number): void { + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; +} + +export function createInverseMaskPainter(document: Document) { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + const walkable = document.createElement("canvas"); + const expanded = document.createElement("canvas"); + const walkableContext = walkable.getContext("2d"); + const expandedContext = expanded.getContext("2d"); + const draw = (input: InverseMaskPaint, dpr: number): HTMLCanvasElement | null => { + if (context === null || walkableContext === null || expandedContext === null) return null; + const width = Math.max(1, Math.round(input.projection.box.width * dpr)); + const height = Math.max(1, Math.round(input.projection.box.height * dpr)); + for (const target of [canvas, walkable, expanded]) sizeCanvas(target, width, height); + + const { a, b, c, d, e, f } = input.projection.transform; + walkableContext.setTransform(1, 0, 0, 1, 0, 0); + walkableContext.clearRect(0, 0, width, height); + walkableContext.save(); + walkableContext.scale(dpr, dpr); + clipProjection(walkableContext, input.projection); + walkableContext.setTransform(a * dpr, b * dpr, c * dpr, d * dpr, e * dpr, f * dpr); + walkableContext.imageSmoothingEnabled = true; + walkableContext.drawImage(input.terrain.canvas, 0, 0); + walkableContext.restore(); + + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, width, height); + context.save(); + context.scale(dpr, dpr); + clipProjection(context, input.projection); + context.globalAlpha = input.opacity / 100; + context.fillStyle = input.style.veilColor; + context.fillRect(0, 0, input.projection.box.width, input.projection.box.height); + context.restore(); + context.globalCompositeOperation = "destination-out"; + context.drawImage(walkable, 0, 0); + context.globalCompositeOperation = "source-over"; + + const outlineRadius = Math.round(input.style.boundaryWidth * dpr); + if (outlineRadius > 0) { + const paintOutline = (radius: number, color: string, alpha: number): void => { + expandedContext.setTransform(1, 0, 0, 1, 0, 0); + expandedContext.clearRect(0, 0, width, height); + expandedContext.globalCompositeOperation = "source-over"; + // One blurred alpha shadow replaces the old radius-squared loop of + // full-surface copies. The core is removed below, leaving only the rim. + expandedContext.shadowColor = color; + expandedContext.shadowBlur = Math.max(1, radius * 1.35); + expandedContext.drawImage(walkable, 0, 0); + expandedContext.shadowColor = "transparent"; + expandedContext.shadowBlur = 0; + expandedContext.globalCompositeOperation = "destination-out"; + expandedContext.drawImage(walkable, 0, 0); + expandedContext.globalCompositeOperation = "source-in"; + expandedContext.fillStyle = color; + expandedContext.fillRect(0, 0, width, height); + expandedContext.globalCompositeOperation = "source-over"; + context.save(); + context.globalAlpha = alpha; + context.drawImage(expanded, 0, 0); + context.restore(); + }; + const strength = input.opacity / 100; + paintOutline( + outlineRadius + Math.max(1, Math.round(dpr)), + input.style.boundaryCasingColor, + Math.min(1, strength * 1.45), + ); + paintOutline( + outlineRadius, + input.style.boundaryColor, + Math.min(1, strength * 1.6), + ); + } + return canvas; + }; + + return Object.freeze({ canvas, draw, dispose() { for (const target of [canvas, walkable, expanded]) { target.width = 0; target.height = 0; } } }); +} diff --git a/src/renderer/cartography-spike/map-projections.ts b/src/renderer/cartography-spike/map-projections.ts index 9f16fa30..aee4ab49 100644 --- a/src/renderer/cartography-spike/map-projections.ts +++ b/src/renderer/cartography-spike/map-projections.ts @@ -217,18 +217,6 @@ function projectTerrain( }); } -export function projectTerrainToCompass(input: Readonly<{ - box: ScreenBox; - terrain: WalkableTerrainSurface; - playerMapX: number; - playerMapY: number; - directionX: number; - directionY: number; -}>): InverseMaskProjection | null { - const projection = projectMapUnitsToCompass(input); - return projection === null ? null : projectTerrain(projection, input.terrain); -} - export function projectTerrainToMissionMap(input: Readonly<{ frame: MissionMapFrameSpikeSnapshot; box: ScreenBox; diff --git a/src/renderer/cartography-spike/native-compass-ranges-layer.ts b/src/renderer/cartography-spike/native-compass-ranges-layer.ts new file mode 100644 index 00000000..64a02439 --- /dev/null +++ b/src/renderer/cartography-spike/native-compass-ranges-layer.ts @@ -0,0 +1,68 @@ +/** + * Copies range artwork into its own native Compass Canvas draw object. + * Native camera frames reuse that texture until range styling or scale changes. + */ +import { NATIVE_COMPASS_TERRAIN_HEADER_BYTES, NATIVE_COMPASS_TERRAIN_MAGIC, + NATIVE_COMPASS_TERRAIN_MAX_SIZE, NATIVE_COMPASS_TERRAIN_WORLD_SPAN } from "../../shared/native-compass-terrain.js"; +import type { MapPainterImage } from "./native-map-graphics-layer.js"; + +export function createNativeCompassRangesLayer(exports: WebAssembly.Exports, document: Document) { + const memory = exports.memory; const malloc = exports.malloc; const free = exports.free; + const publish = exports.gwonmac_compass_ranges_publish; const hide = exports.gwonmac_compass_ranges_hide; + const serial = exports.gwonmac_compass_ranges_serial; + const nativeArea = exports.gwonmac_compass_ranges_area; + const canvas = document.createElement("canvas"); + let version = ""; let nextSerial = 0; let disposed = false; + const withdraw = () => { if (typeof hide === "function") hide(); version = ""; }; + const view = document.defaultView; + view?.addEventListener("gw:graphics-context-reset", withdraw); + return Object.freeze({ + available: () => !disposed && memory instanceof WebAssembly.Memory + && typeof malloc === "function" && typeof free === "function" + && typeof publish === "function" && typeof hide === "function" + && serial instanceof WebAssembly.Global && nativeArea instanceof WebAssembly.Global, + update(image: MapPainterImage | null, area: number) { + if (disposed) return; + if (!image || !Number.isInteger(area) || area <= 0 || area > 0x7fffffff) { withdraw(); return; } + if (!(memory instanceof WebAssembly.Memory) || typeof malloc !== "function" || typeof free !== "function" + || typeof publish !== "function" || !(serial instanceof WebAssembly.Global) || !(nativeArea instanceof WebAssembly.Global)) return; + const key = `${area}:${image.version}`; + if (version === key && Number(serial.value) === nextSerial && Number(nativeArea.value) === area) return; + const size = Math.min(NATIVE_COMPASS_TERRAIN_MAX_SIZE, Math.max(64, 2 ** Math.ceil(Math.log2(Math.max(image.canvas.width, image.canvas.height))))); + if (canvas.width !== size) canvas.width = size; + if (canvas.height !== size) canvas.height = size; + const context = canvas.getContext("2d"); + if (!context) { withdraw(); return; } + context.clearRect(0, 0, size, size); context.drawImage(image.canvas, 0, 0, size, size); + const pixels = context.getImageData(0, 0, size, size).data; + const bytes = NATIVE_COMPASS_TERRAIN_HEADER_BYTES + pixels.length; + // The certified wasm32 allocator returns signed i32 bits, including addresses above 2 GiB. + const region = Number(malloc(bytes)) >>> 0; + if (!Number.isSafeInteger(region) || region <= 0 || region % 4 !== 0 || region + bytes > memory.buffer.byteLength) { + if (Number.isSafeInteger(region) && region > 0) free(region); + withdraw(); return; + } + try { + nextSerial = nextSerial >= 0x7ffffffe ? 1 : nextSerial + 1; + const header = new DataView(memory.buffer, region, NATIVE_COMPASS_TERRAIN_HEADER_BYTES); + [NATIVE_COMPASS_TERRAIN_MAGIC, bytes, area, size].forEach((value, index) => header.setUint32(index * 4, value, true)); + [-8192, 8192, NATIVE_COMPASS_TERRAIN_WORLD_SPAN].forEach((value, index) => header.setFloat32(16 + index * 4, value, true)); + header.setUint32(28, nextSerial, true); + const output = new Uint8Array(memory.buffer, region + NATIVE_COMPASS_TERRAIN_HEADER_BYTES, pixels.length); + for (let at = 0; at < pixels.length; at += 4) { + output[at] = pixels[at + 2]!; output[at + 1] = pixels[at + 1]!; + output[at + 2] = pixels[at]!; output[at + 3] = pixels[at + 3]!; + } + if (publish(region, bytes) === 1) version = key; else withdraw(); + } finally { free(region); } + }, + dispose() { + if (disposed) return; + disposed = true; + try { withdraw(); } finally { + view?.removeEventListener("gw:graphics-context-reset", withdraw); + canvas.width = 0; canvas.height = 0; + } + }, + }); +} diff --git a/src/renderer/cartography-spike/native-compass-terrain-layer.ts b/src/renderer/cartography-spike/native-compass-terrain-layer.ts new file mode 100644 index 00000000..864a18a8 --- /dev/null +++ b/src/renderer/cartography-spike/native-compass-terrain-layer.ts @@ -0,0 +1,181 @@ +/** + * Owns one nearby terrain bitmap for the native Compass Canvas. + * Native camera motion changes UVs; map, tile, style and scale changes repaint. + */ +import type { CartographyWalkabilityStyle } from "../../shared/cartography-overlay.js"; +import { + NATIVE_COMPASS_TERRAIN_HEADER_BYTES, NATIVE_COMPASS_TERRAIN_MAGIC, + NATIVE_COMPASS_TERRAIN_MAX_SIZE, NATIVE_COMPASS_TERRAIN_WORLD_SPAN, + NATIVE_COMPASS_WORLD_SCALE, +} from "../../shared/native-compass-terrain.js"; +import { cartographyWalkabilityStyleFingerprint } from "./cartography-paint.js"; +import { createInverseMaskPainter } from "./inverse-mask-painter.js"; +import { createCartographyGridLayer, type CartographyGridLayer, type CartographyGridLayerSnapshot } from "./cartography-grid-layer.js"; +import { cartographyCellAt } from "./cartography-grid-projection.js"; +import { cartographyGridStyleFingerprint } from "./cartography-paint.js"; +import type { WalkableTerrainSurface } from "./walkable-terrain-surface.js"; + +export type NativeCompassTerrainInput = Readonly<{ + area: number; + version: string; + terrain: WalkableTerrainSurface | null; + cartography?: Omit[0], "projection" | "hoveredCell" | "revealRadius">; + inspectionRadius?: 0 | 1 | 3; + anchorX: number; + anchorY: number; + scaleX: number; + scaleY: number; + style: CartographyWalkabilityStyle; + opacity: number; +}>; +export type NativeCompassTerrainLayer = Readonly<{ + available(): boolean; + update(input: NativeCompassTerrainInput): void; + gridSnapshot(): CartographyGridLayerSnapshot | null; + hide(): void; + dispose(): void; +}>; + +/** One tile provides a full Compass radius plus margin between repaints. */ +export function nativeCompassTerrainTile(input: Readonly<{ + cameraX: number; cameraY: number; width: number; height: number; + scaleX: number; scaleY: number; dpr: number; +}>) { + if (!Object.values(input).every(Number.isFinite) + || Math.abs(input.cameraX) > 1000000 || Math.abs(input.cameraY) > 1000000 + || input.width <= 0 || input.height <= 0 || input.scaleX <= 0 || input.scaleY <= 0 + || input.dpr <= 0 || input.dpr > 8) return null; + const cssWorldScale = Math.max(input.width * input.scaleX, input.height * input.scaleY) * NATIVE_COMPASS_WORLD_SCALE; + const size = Math.max(64, 2 ** Math.ceil(Math.log2(NATIVE_COMPASS_TERRAIN_WORLD_SPAN * cssWorldScale * input.dpr))); + if (size > NATIVE_COMPASS_TERRAIN_MAX_SIZE) return null; + const left = Math.round(input.cameraX / 4096) * 4096 - NATIVE_COMPASS_TERRAIN_WORLD_SPAN / 2; + const top = Math.round(input.cameraY / 4096) * 4096 + NATIVE_COMPASS_TERRAIN_WORLD_SPAN / 2; + return Object.freeze({ left, top, size, cssWorldScale, + paintScale: size / NATIVE_COMPASS_TERRAIN_WORLD_SPAN / cssWorldScale }); +} + +export function createNativeCompassTerrainLayer(exports: WebAssembly.Exports, document: Document): NativeCompassTerrainLayer { + const memory = exports.memory; + const malloc = exports.malloc; const free = exports.free; + const publish = exports.gwonmac_compass_terrain_publish; + const withdraw = exports.gwonmac_compass_terrain_hide; + const names = ["area", "serial", "updates", "uploads", "created", "destroyed", "size", "camera_x", "camera_y", "width", "height"] as const; + const globals = names.map((name) => exports[`gwonmac_compass_terrain_${name}`]); + if (!(memory instanceof WebAssembly.Memory) || typeof malloc !== "function" || typeof free !== "function" + || typeof publish !== "function" || typeof withdraw !== "function" + || !globals.every((value) => value instanceof WebAssembly.Global)) { + return Object.freeze({ available: () => false, update() {}, gridSnapshot: () => null, hide() {}, dispose() {} }); + } + const [area, serial, updates, uploads, created, destroyed, textureSize, cameraX, cameraY, width, height] = globals; + if (!area || !serial || !updates || !uploads || !created || !destroyed || !textureSize || !cameraX || !cameraY || !width || !height) { + throw new Error("incomplete native Compass terrain surface"); + } + const painter = createInverseMaskPainter(document); + const grid = createCartographyGridLayer(document); + let disposed = false; + let version = ""; + let nextSerial = 0; + let outcome: "hidden" | "ready" | "unavailable" = "hidden"; + const hide = () => { withdraw(); grid.hide(); version = ""; outcome = "hidden"; }; + const snapshot = () => ({ status: outcome, area: Number(area.value), + updates: Number(updates.value), uploads: Number(uploads.value), + created: Number(created.value), destroyed: Number(destroyed.value), textureSize: Number(textureSize.value) }); + window.gwNativeCompassTerrainStats = snapshot; + const reset = () => { if (!disposed) hide(); }; + window.addEventListener("gw:graphics-context-reset", reset); + + return Object.freeze({ + available: () => !disposed, + update(input) { + if (disposed) return; + if ((input.opacity <= 0 && !input.cartography) || !Number.isInteger(input.area) || input.area <= 0 || input.area > 0x7fffffff) { hide(); return; } + const tile = nativeCompassTerrainTile({ cameraX: Number(cameraX.value), cameraY: Number(cameraY.value), + width: Number(width.value), height: Number(height.value), scaleX: input.scaleX, scaleY: input.scaleY, + dpr: document.defaultView?.devicePixelRatio ?? 1 }); + if (tile === null || ![input.anchorX, input.anchorY].every(Number.isFinite)) { hide(); outcome = "unavailable"; return; } + const currentCell = cartographyCellAt(input.anchorX + Number(cameraX.value) / 96, input.anchorY - Number(cameraY.value) / 96); + const inspectionRadius = input.cartography ? input.inspectionRadius ?? 0 : 0; + const inspectionKey = inspectionRadius > 0 && currentCell ? `${inspectionRadius}:${currentCell.x}:${currentCell.y}` : "off"; + const key = [inspectionKey, input.area, input.version, tile.left, tile.top, tile.size, tile.cssWorldScale, + input.anchorX, input.anchorY, cartographyWalkabilityStyleFingerprint(input.style), input.opacity, + input.cartography ? [input.cartography.explorationVersion, input.cartography.revealabilityVersion, + cartographyGridStyleFingerprint(input.cartography.style), input.cartography.opacity].join(":") : "no-grid"].join(":"); + if (key === version && Number(serial.value) === nextSerial && Number(area.value) === input.area) return; + const mapLeft = input.anchorX + tile.left / 96; + const mapTop = input.anchorY - tile.top / 96; + const logicalSize = tile.size / tile.paintScale; + const mapScale = tile.cssWorldScale * 96; + const canvas = input.terrain === null || input.opacity <= 0 ? painter.canvas : painter.draw({ terrain: input.terrain, version: input.version, style: input.style, opacity: input.opacity, + projection: { + box: {left: 0, top: 0, width: logicalSize, height: logicalSize}, clip: {kind: "rectangle"}, + transform: {a: mapScale * input.terrain.mapUnitsPerPixel, b: 0, c: 0, d: mapScale * input.terrain.mapUnitsPerPixel, + e: (input.terrain.mapLeft - mapLeft) * mapScale, f: (input.terrain.mapTop - mapTop) * mapScale}, + }, + }, tile.paintScale); + if (input.terrain === null || input.opacity <= 0) { + if (painter.canvas.width !== tile.size) painter.canvas.width = tile.size; + if (painter.canvas.height !== tile.size) painter.canvas.height = tile.size; + const blank = painter.canvas.getContext("2d"); + blank?.setTransform(1, 0, 0, 1, 0, 0); blank?.clearRect(0, 0, tile.size, tile.size); + } + if (input.cartography) { + const span = NATIVE_COMPASS_TERRAIN_WORLD_SPAN / 96; + if (currentCell) grid.update({ ...input.cartography, hoveredCell: inspectionRadius > 0 ? currentCell : null, revealRadius: inspectionRadius, + projection: { surface: "compass", box: {left: 0, top: 0, width: logicalSize, height: logicalSize}, + clip: {kind: "rectangle"}, transform: {a: mapScale, b: 0, c: 0, d: mapScale, e: -mapLeft * mapScale, f: -mapTop * mapScale}, + firstCellX: Math.floor(mapLeft / 32), lastCellX: Math.floor((mapLeft + span) / 32), + firstCellY: Math.floor(mapTop / 32), lastCellY: Math.floor((mapTop + span) / 32), currentCell, + }, + }); + else grid.hide(); + } else grid.hide(); + const gridImage = grid.image(); + const composite = canvas?.getContext("2d"); + if (composite && gridImage) { + composite.save(); composite.setTransform(1, 0, 0, 1, 0, 0); + composite.drawImage(gridImage.canvas, 0, 0, tile.size, tile.size); composite.restore(); + } + const context = canvas?.getContext("2d"); + if (!canvas || !context) { hide(); outcome = "unavailable"; return; } + const pixels = context.getImageData(0, 0, tile.size, tile.size).data; + const bytes = NATIVE_COMPASS_TERRAIN_HEADER_BYTES + pixels.length; + // The existing native allocator and copied GrTex input remain private to + // this loader. No pointer or generic write is published through diagnostics. + // The certified wasm32 allocator returns signed i32 bits, including addresses above 2 GiB. + const region = Number(malloc(bytes)) >>> 0; + if (!Number.isSafeInteger(region) || region <= 0 || region % 4 !== 0 || region + bytes > memory.buffer.byteLength) { + if (Number.isSafeInteger(region) && region > 0) free(region); + hide(); outcome = "unavailable"; return; + } + try { + nextSerial = nextSerial >= 0x7ffffffe ? 1 : nextSerial + 1; + const header = new DataView(memory.buffer, region, NATIVE_COMPASS_TERRAIN_HEADER_BYTES); + header.setUint32(0, NATIVE_COMPASS_TERRAIN_MAGIC, true); header.setUint32(4, bytes, true); + header.setUint32(8, input.area, true); header.setUint32(12, tile.size, true); + header.setFloat32(16, tile.left, true); header.setFloat32(20, tile.top, true); + header.setFloat32(24, NATIVE_COMPASS_TERRAIN_WORLD_SPAN, true); header.setUint32(28, nextSerial, true); + const target = new Uint8Array(memory.buffer, region + NATIVE_COMPASS_TERRAIN_HEADER_BYTES, pixels.length); + // GrTex format 0 is the native packed ARGB control; its byte order is BGRA. + for (let at = 0; at < pixels.length; at += 4) { + target[at] = pixels[at + 2]!; target[at + 1] = pixels[at + 1]!; + target[at + 2] = pixels[at]!; target[at + 3] = pixels[at + 3]!; + } + if (publish(region, bytes) !== 1) { hide(); outcome = "unavailable"; return; } + version = key; outcome = "ready"; + } finally { + free(region); + } + }, + gridSnapshot: () => grid.snapshot(), + hide, + dispose() { + if (disposed) return; + disposed = true; + try { hide(); } finally { + painter.dispose(); grid.dispose(); + window.removeEventListener("gw:graphics-context-reset", reset); + if (window.gwNativeCompassTerrainStats === snapshot) delete window.gwNativeCompassTerrainStats; + } + }, + }); +} diff --git a/src/renderer/cartography-spike/native-map-graphics-layer.ts b/src/renderer/cartography-spike/native-map-graphics-layer.ts new file mode 100644 index 00000000..89a457bd --- /dev/null +++ b/src/renderer/cartography-spike/native-map-graphics-layer.ts @@ -0,0 +1,125 @@ +/** + * Composes map painters into bounded textures for native Mission and World Map. + * These canvases stay detached; only each game's map draw event presents them. + */ +import { disposeCartographyResources } from "../cartography-lifecycle.js"; +import { NATIVE_MAP_GRAPHICS_HEADER_BYTES, NATIVE_MAP_GRAPHICS_MAGIC, + NATIVE_MAP_GRAPHICS_MAX_SIZE, NATIVE_MAP_GRAPHICS_SURFACES, + type NativeMapGraphicsSurface } from "../../shared/native-map-graphics.js"; +import type { MapUnitProjection } from "./map-projections.js"; + +export type MapPainterImage = Readonly<{ canvas: HTMLCanvasElement; version: string }>; +export type NativeMapGraphicsLayer = Readonly<{ + available(surface: NativeMapGraphicsSurface): boolean; + update(surface: NativeMapGraphicsSurface, input: Readonly<{ + area: number; continent: number; projection: MapUnitProjection; + images: readonly (MapPainterImage | null)[]; + }>): void; + hide(surface: NativeMapGraphicsSurface): void; + dispose(): void; +}>; + +/** Convert the painted corners back into native world-map units. */ +export function nativeMapGraphicsCorners(projection: MapUnitProjection): readonly number[] | null { + const { a, b, c, d, e, f } = projection.transform; + const { width, height } = projection.box; + const determinant = a * d - b * c; + if (![a, b, c, d, e, f, width, height].every(Number.isFinite) + || width <= 0 || height <= 0 || Math.abs(determinant) < 1e-10) return null; + const corners = [[0, 0], [width, 0], [width, height], [0, height]].flatMap(([x = 0, y = 0]) => [ + (d * (x - e) - c * (y - f)) / determinant, + (a * (y - f) - b * (x - e)) / determinant, + ]); + return corners.every((value) => Number.isFinite(value) && Math.abs(value) <= 1000000) ? corners : null; +} + +export function createNativeMapGraphicsLayer(exports: WebAssembly.Exports, document: Document): NativeMapGraphicsLayer { + const memory = exports.memory; const malloc = exports.malloc; const free = exports.free; + const surfaces = NATIVE_MAP_GRAPHICS_SURFACES.map((name) => { + const publish = exports[`gwonmac_${name}_graphics_publish`]; + const hide = exports[`gwonmac_${name}_graphics_hide`]; + const serial = exports[`gwonmac_${name}_graphics_serial`]; + if (typeof publish !== "function" || typeof hide !== "function" || !(serial instanceof WebAssembly.Global)) return null; + return { name, publish, hide, serial, canvas: document.createElement("canvas"), version: "", nextSerial: 0 }; + }); + let disposed = false; + const withdraw = (surface: NativeMapGraphicsSurface) => { + const target = surfaces.find((item) => item?.name === surface); + if (target) { target.hide(); target.version = ""; } + }; + const reset = () => { for (const name of NATIVE_MAP_GRAPHICS_SURFACES) withdraw(name); }; + const view = document.defaultView; + view?.addEventListener("gw:graphics-context-reset", reset); + const stats = () => { + const read = (surface: NativeMapGraphicsSurface | "ranges") => { + const prefix = surface === "ranges" ? "gwonmac_compass_ranges" : `gwonmac_${surface}_graphics`; + const names = ["area", "uploads", "draws", "created", "destroyed"] as const; + const values = names.map((name) => exports[`${prefix}_${surface === "ranges" && name === "draws" ? "updates" : name}`]); + if (!values.every((value) => value instanceof WebAssembly.Global)) return null; + return {area: Number(values[0]?.value), uploads: Number(values[1]?.value), draws: Number(values[2]?.value), + created: Number(values[3]?.value), destroyed: Number(values[4]?.value)}; + }; + return {mission: read("mission"), world: read("world"), mission_hover: read("mission_hover"), world_hover: read("world_hover"), ranges: read("ranges")}; + }; + if (view) view.gwNativeMapGraphicsStats = stats; + return Object.freeze({ + available: surface => !disposed && memory instanceof WebAssembly.Memory + && typeof malloc === "function" && typeof free === "function" + && surfaces.some(item => item?.name === surface), + update(surface, input) { + const target = surfaces.find((item) => item?.name === surface); + if (disposed || !target) return; + if (!(memory instanceof WebAssembly.Memory) || typeof malloc !== "function" || typeof free !== "function" + || !Number.isInteger(input.area) || input.area <= 0 || input.area > 0x7fffffff) { withdraw(surface); return; } + const images = input.images.filter((item): item is MapPainterImage => item !== null); + const corners = nativeMapGraphicsCorners(input.projection); + if (!images.length || corners === null) { withdraw(surface); return; } + const key = [input.area, input.continent, ...corners, ...input.images.map((image) => image?.version ?? "hidden")].join(":"); + if (key === target.version && Number(target.serial.value) === target.nextSerial) return; + const largestWidth = Math.max(...images.map(({canvas}) => canvas.width)); + const largestHeight = Math.max(...images.map(({canvas}) => canvas.height)); + const dimension = (size: number) => Math.min(NATIVE_MAP_GRAPHICS_MAX_SIZE, Math.max(64, 2 ** Math.ceil(Math.log2(size)))); + const width = dimension(largestWidth); const height = dimension(largestHeight); + if (target.canvas.width !== width) target.canvas.width = width; + if (target.canvas.height !== height) target.canvas.height = height; + const context = target.canvas.getContext("2d"); + if (!context) { withdraw(surface); return; } + context.clearRect(0, 0, width, height); + for (const { canvas } of images) context.drawImage(canvas, 0, 0, width, height); + const pixels = context.getImageData(0, 0, width, height).data; + const bytes = NATIVE_MAP_GRAPHICS_HEADER_BYTES + pixels.length; + // The certified wasm32 allocator returns signed i32 bits, including addresses above 2 GiB. + const region = Number(malloc(bytes)) >>> 0; + if (!Number.isSafeInteger(region) || region <= 0 || region % 4 !== 0 || region + bytes > memory.buffer.byteLength) { + if (Number.isSafeInteger(region) && region > 0) free(region); + withdraw(surface); return; + } + try { + target.nextSerial = target.nextSerial >= 0x7ffffffe ? 1 : target.nextSerial + 1; + const header = new DataView(memory.buffer, region, NATIVE_MAP_GRAPHICS_HEADER_BYTES); + [NATIVE_MAP_GRAPHICS_MAGIC, bytes, input.area, width, height, target.nextSerial, input.continent, 0] + .forEach((value, index) => header.setUint32(index * 4, value, true)); + corners.forEach((value, index) => header.setFloat32(32 + index * 4, value, true)); + const output = new Uint8Array(memory.buffer, region + NATIVE_MAP_GRAPHICS_HEADER_BYTES, pixels.length); + for (let at = 0; at < pixels.length; at += 4) { + output[at] = pixels[at + 2]!; output[at + 1] = pixels[at + 1]!; + output[at + 2] = pixels[at]!; output[at + 3] = pixels[at + 3]!; + } + if (target.publish(region, bytes) === 1) target.version = key; + else withdraw(surface); + } finally { free(region); } + }, + hide: withdraw, + dispose() { + if (disposed) return; + if (view?.gwNativeMapGraphicsStats === stats) delete view.gwNativeMapGraphicsStats; + disposed = true; + try { + disposeCartographyResources(NATIVE_MAP_GRAPHICS_SURFACES.map(name => () => withdraw(name))); + } finally { + view?.removeEventListener("gw:graphics-context-reset", reset); + for (const target of surfaces) if (target) { target.canvas.width = 0; target.canvas.height = 0; } + } + }, + }); +} diff --git a/src/renderer/cartography-spike/native-map-tile.ts b/src/renderer/cartography-spike/native-map-tile.ts new file mode 100644 index 00000000..253138ac --- /dev/null +++ b/src/renderer/cartography-spike/native-map-tile.ts @@ -0,0 +1,47 @@ +/** + * Anchors cached map artwork to world units with a margin around the viewport. + * Native map matrices move that artwork every frame; only tile changes repaint. + */ +import { CARTOGRAPHY_CELL_MAP_UNITS, type CartographyGridProjection, type CartographyCell, + type CartographyRevealRadius } from "./cartography-grid-projection.js"; + +function tileAxis(scale: number, translation: number, pixels: number, paintScale: number) { + const visibleStart = -translation / scale; + const span = pixels / scale; + // About 128–256 screen pixels per tile step keep panning away from texture + // edges without allocating a second full-screen drawing surface every frame. + const step = 2 ** Math.ceil(Math.log2(128 / paintScale)); + const start = (Math.floor(visibleStart / step) - 1) * step; + const end = start + (Math.ceil(span / step) + 3) * step; + return {start, end}; +} + +function worldRectangle(projection: CartographyGridProjection, left: number, top: number, right: number, bottom: number): CartographyGridProjection { + const {a, d} = projection.transform; + return {...projection, box: {left: 0, top: 0, width: (right - left) * a, height: (bottom - top) * d}, + transform: {a, b: 0, c: 0, d, e: -left * a, f: -top * d}, clip: {kind: "rectangle"}, + firstCellX: Math.floor(left / CARTOGRAPHY_CELL_MAP_UNITS), lastCellX: Math.ceil(right / CARTOGRAPHY_CELL_MAP_UNITS) - 1, + firstCellY: Math.floor(top / CARTOGRAPHY_CELL_MAP_UNITS), lastCellY: Math.ceil(bottom / CARTOGRAPHY_CELL_MAP_UNITS) - 1, + }; +} + +export function nativeMapTileProjection(projection: CartographyGridProjection): CartographyGridProjection { + const {a, b, c, d, e, f} = projection.transform; + if (b !== 0 || c !== 0 || a <= 0 || d <= 0) throw new Error("native map tile requires an axis-aligned map"); + // Native projection remains continuous during zoom. Raster detail changes + // only at eighth-octave steps instead of uploading on every animation frame. + const detail = (scale: number) => 2 ** (Math.round(Math.log2(scale) * 8) / 8); + const paintA = detail(a); const paintD = detail(d); + const x = tileAxis(a, e, projection.box.width, paintA); const y = tileAxis(d, f, projection.box.height, paintD); + const tile = worldRectangle({...projection, transform: {...projection.transform, a: paintA, d: paintD}}, x.start, y.start, x.end, y.end); + // Player motion cannot invalidate a viewport tile: current-cell inspection + // is a separate small texture. The grid's diagnostics use this fixed origin. + return {...tile, currentCell: {x: tile.firstCellX, y: tile.firstCellY}}; +} + +export function nativeMapHoverProjection(projection: CartographyGridProjection, cell: CartographyCell, radius: CartographyRevealRadius): CartographyGridProjection { + const size = CARTOGRAPHY_CELL_MAP_UNITS; + const marginX = 6 / projection.transform.a; const marginY = 6 / projection.transform.d; + return worldRectangle({...projection, currentCell: cell}, (cell.x - radius) * size - marginX, (cell.y - radius) * size - marginY, + (cell.x + radius + 1) * size + marginX, (cell.y + radius + 1) * size + marginY); +} diff --git a/src/renderer/cartography-spike/overlay.ts b/src/renderer/cartography-spike/overlay.ts index 87449294..b9b7c22f 100644 --- a/src/renderer/cartography-spike/overlay.ts +++ b/src/renderer/cartography-spike/overlay.ts @@ -2,6 +2,7 @@ * Owns presentation of immutable Cartography models through independent surfaces. * Native observation and semantic classification never occur on animation frames. */ +import { disposeCartographyResources } from "../cartography-lifecycle.js"; import { resolveCartographyPreset } from "../../shared/cartography-presets.js"; import type { CartographyEvidenceCapture, @@ -24,22 +25,20 @@ import { } from "./cartography-grid-layer.js"; import { cartographyCellAtScreenPoint, - projectCartographyGridToCompass, projectCartographyGridToMissionMap, projectCartographyGridToWorldMap, } from "./cartography-grid-projection.js"; import { cartographyHoverRevealRadius } from "./cartography-paint.js"; import { projectMissionMapFrame, projectNativeFrame } from "./frame-placement.js"; import { createInverseMaskLayer } from "./inverse-mask-layer.js"; +import { nativeMapTileProjection, nativeMapHoverProjection } from "./native-map-tile.js"; +import type { createNativeCompassRangesLayer } from "./native-compass-ranges-layer.js"; +import type { NativeMapGraphicsLayer } from "./native-map-graphics-layer.js"; +import type { NativeCompassTerrainLayer } from "./native-compass-terrain-layer.js"; import { - GAME_UNITS_PER_MAP_UNIT, projectMissionMapContentBox, - projectTerrainToCompass, - projectTerrainToMissionMap, } from "./map-projections.js"; import { captureCartographyEvidence } from "./evidence-capture.js"; -import { createContinentCoverageLayer } from "./continent-coverage-layer.js"; -import { createCurrentInstanceBoundaryLayer } from "./current-instance-boundary-layer.js"; import { createCartographyOverlayControls, type CartographyQaStatus, @@ -108,6 +107,9 @@ export function cartographyOverlayDisposition( } export function mountCartographyOverlay(options: Readonly<{ + compassTerrainLayer: NativeCompassTerrainLayer; + nativeMapGraphics: NativeMapGraphicsLayer; + nativeCompassRanges: ReturnType; parent: HTMLElement; canvas: HTMLCanvasElement; modelSources: CartographyModelSources; @@ -124,417 +126,529 @@ export function mountCartographyOverlay(options: Readonly<{ const document = options.parent.ownerDocument; const view = document.defaultView; if (view === null) throw new Error("cartography overlay requires a live document"); - const compassTerrainLayer = createInverseMaskLayer( - options.parent, - "cartography-compass-mask", - ); - const missionTerrainLayer = createInverseMaskLayer( - options.parent, - "cartography-mission-map-mask", - ); - const compassGridLayer = createCartographyGridLayer( - options.parent, - "cartography-compass-grid", - ); - const missionGridLayer = createCartographyGridLayer( - options.parent, - "cartography-mission-map-grid", - ); - const missionCoverageLayer = createContinentCoverageLayer( - options.parent, - "cartography-mission-map-coverage", - ); - const missionBoundaryLayer = createCurrentInstanceBoundaryLayer( - options.parent, - "cartography-mission-map-current-instance-boundary", - ); - const worldGridLayer = createCartographyGridLayer( - options.parent, - "cartography-world-map-grid", - ); - const worldCoverageLayer = createContinentCoverageLayer( - options.parent, - "cartography-world-map-coverage", - ); - const worldBoundaryLayer = createCurrentInstanceBoundaryLayer( - options.parent, - "cartography-world-map-current-instance-boundary", - ); - const compassRangeLayer = createCompassRangeLayer(options.parent); - const previewRangeOpacity = new Map(); - const compassRangeControls = createCompassRangeControls({ - parent: options.parent, - persist: options.persist, - previewOpacity(id, opacity) { - if (opacity === null) previewRangeOpacity.delete(id); - else previewRangeOpacity.set(id, opacity); - }, - }); - let previewGridOpacity: number | null = null; - let previewWalkabilityOpacity: number | null = null; - const controls = createCartographyOverlayControls({ - parent: options.parent, - persist: options.persist, - previewOpacity(layer, opacity) { - if (layer === "grid") previewGridOpacity = opacity; - else previewWalkabilityOpacity = opacity; - }, - exportEvidence: () => options.exportEvidence( - captureCartographyEvidence(state, options.modelSources), - ), - }); - - let animationFrame = 0; - let startupTimer = 0; - let disposed = false; - let state: CartographyState = Object.freeze({ - context: null, - continent: Object.freeze({ status: "unavailable", reason: "context" }), - currentInstance: Object.freeze({ status: "unavailable", reason: "context" }), - surfaces: Object.freeze({ compass: null, missionMap: null, worldMap: null }), - }); - let presentation: CartographyPresentation = Object.freeze({ - player: null, - compass: null, - missionMap: null, - worldMap: null, - }); - let nextModelPoll = 0; - let explorationVersion = 0; - let actionabilityVersion = 0; - let mapKnowledge = options.initialMapKnowledge; - let mapKnowledgeVersion = 0; - let mapKnowledgeProjectionKey = ""; - let rememberedReachable: Readonly<{ - width: number; - height: number; - words: Uint32Array; - }> | null = null; - let requestedKnowledgeKey = ""; - let knowledgeWrites: Promise = Promise.resolve(); - let terrainKey = ""; - let terrain: WalkableTerrainSurface | null = null; - let pointerX = Number.NaN; - let pointerY = Number.NaN; - let shiftHeld = false; - let optionHeld = false; + const cleanup: (() => void)[] = []; + const dispose = () => disposeCartographyResources(cleanup); + try { + const compassTerrainLayer = options.compassTerrainLayer; + const missionTerrainLayer = createInverseMaskLayer(document); + cleanup.push(() => missionTerrainLayer.dispose()); + const missionGridLayer = createCartographyGridLayer(document); + cleanup.push(() => missionGridLayer.dispose()); + const worldGridLayer = createCartographyGridLayer(document); + cleanup.push(() => worldGridLayer.dispose()); + const missionHoverLayer = createCartographyGridLayer(document, true); + cleanup.push(() => missionHoverLayer.dispose()); + const worldHoverLayer = createCartographyGridLayer(document, true); + cleanup.push(() => worldHoverLayer.dispose()); + const compassRangeLayer = createCompassRangeLayer(options.parent); + cleanup.push(() => compassRangeLayer.dispose()); + const previewRangeOpacity = new Map(); + const compassRangeControls = createCompassRangeControls({ + parent: options.parent, + persist: options.persist, + previewOpacity(id, opacity) { + if (opacity === null) previewRangeOpacity.delete(id); + else previewRangeOpacity.set(id, opacity); + }, + }); + cleanup.push(() => compassRangeControls.dispose()); + let previewGridOpacity: number | null = null; + let previewWalkabilityOpacity: number | null = null; + const controls = createCartographyOverlayControls({ + parent: options.parent, + persist: options.persist, + previewOpacity(layer, opacity) { + if (layer === "grid") previewGridOpacity = opacity; + else previewWalkabilityOpacity = opacity; + }, + exportEvidence: () => options.exportEvidence( + captureCartographyEvidence(state, options.modelSources), + ), + }); - const rememberPointer = (event: PointerEvent): void => { - pointerX = event.clientX; - pointerY = event.clientY; - shiftHeld = event.shiftKey; - optionHeld = event.altKey; - }; - const rememberModifiers = (event: KeyboardEvent): void => { - shiftHeld = event.shiftKey; - optionHeld = event.altKey; - }; - const forgetPointer = (): void => { - pointerX = Number.NaN; - pointerY = Number.NaN; - shiftHeld = false; - optionHeld = false; - }; - const forgetHiddenPointer = (): void => { - if (document.visibilityState === "hidden") forgetPointer(); - }; - view.addEventListener("pointermove", rememberPointer, { capture: true, passive: true }); - view.addEventListener("keydown", rememberModifiers, true); - view.addEventListener("keyup", rememberModifiers, true); - view.addEventListener("blur", forgetPointer); - document.addEventListener("visibilitychange", forgetHiddenPointer); + cleanup.push(() => controls.dispose()); - const hideCompassLayers = (): void => { - compassGridLayer.hide(); - compassTerrainLayer.hide(); - }; - const hideMission = (): void => { - missionGridLayer.hide(); - missionTerrainLayer.hide(); - missionCoverageLayer.hide(); - missionBoundaryLayer.hide(); - }; - const hideWorld = (): void => { - worldGridLayer.hide(); - worldCoverageLayer.hide(); - worldBoundaryLayer.hide(); - }; - const hideAllLayers = (): void => { - hideCompassLayers(); - hideMission(); - hideWorld(); - }; - const gridStats = (): CartographyGridStats => { - return Object.freeze({ - compass: compassGridLayer.snapshot(), - missionMap: missionGridLayer.snapshot(), - worldMap: worldGridLayer.snapshot(), + let animationFrame = 0; + let startupTimer = 0; + let disposed = false; + cleanup.push(() => { + disposed = true; + if (startupTimer !== 0) view.clearTimeout(startupTimer); + view.cancelAnimationFrame(animationFrame); + }); + let state: CartographyState = Object.freeze({ + context: null, + continent: Object.freeze({ status: "unavailable", reason: "context" }), + currentInstance: Object.freeze({ status: "unavailable", reason: "context" }), + surfaces: Object.freeze({ compass: null, missionMap: null, worldMap: null }), }); - }; - view.gwCartographyGridStats = gridStats; - const rangeStats = (): CompassRangeStats => compassRangeLayer.snapshot(); - view.gwCompassRangeStats = rangeStats; - const modelStats = (): CartographyModelStats => Object.freeze({ - continent: state.continent.status === "ready" - ? Object.freeze({ - status: "ready" as const, - continentId: state.continent.continent, - exploredCreditableCells: countSetBits(state.continent.exploredCreditable.words), - remainingCells: countSetBits(state.continent.remaining.words), - }) - : state.continent, - currentInstance: state.currentInstance.status === "ready" - ? Object.freeze({ - status: "ready" as const, - sequence: state.currentInstance.sequence, - mapId: state.currentInstance.epoch.mapId, - areaEpoch: state.currentInstance.epoch.area, - resourceGeneration: state.currentInstance.epoch.resource, - terrain: Object.freeze({ - width: state.currentInstance.walkableTerrain.width, - height: state.currentInstance.walkableTerrain.height, - mapUnitsPerPixel: state.currentInstance.walkableTerrain.mapUnitsPerPixel, - }), - reachableCells: countSetBits(state.currentInstance.reachableCells.words), - guidance: state.currentInstance.guidance.status === "ready" - ? Object.freeze({ - status: "ready" as const, - actionableCells: countSetBits( - state.currentInstance.guidance.actionableCells.words, - ), - }) - : state.currentInstance.guidance, - }) - : state.currentInstance, - compassReady: presentation.compass !== null, - missionMapReady: presentation.missionMap !== null - && (state.continent.status === "ready" || state.currentInstance.status === "ready"), - worldMapReady: worldGridLayer.snapshot() !== null, - worldMapObserver: options.modelSources.worldMap.diagnostics(), - kernel: options.modelSources.kernel.diagnostic(), - }); - view.gwCartographyModelStats = modelStats; + let presentation: CartographyPresentation = Object.freeze({ + player: null, + compass: null, + missionMap: null, + worldMap: null, + }); + let nextModelPoll = 0; + let explorationVersion = 0; + let actionabilityVersion = 0; + let mapKnowledge = options.initialMapKnowledge; + let mapKnowledgeVersion = 0; + let mapKnowledgeProjectionKey = ""; + let rememberedReachable: Readonly<{ + width: number; + height: number; + words: Uint32Array; + }> | null = null; + let requestedKnowledgeKey = ""; + let knowledgeWrites: Promise = Promise.resolve(); + let terrainKey = ""; + let terrain: WalkableTerrainSurface | null = null; + let pointerX = Number.NaN; + let pointerY = Number.NaN; + let shiftHeld = false; + let optionHeld = false; - const safe = (surface: "compass" | "mission-map" | "world-map", render: () => void): void => { - try { - render(); - } catch (cause) { - if (surface === "compass") hideCompassLayers(); - else if (surface === "mission-map") hideMission(); - else hideWorld(); - console.error(`[cartography] ${surface} rendering failed`, cause); - } - }; + const rememberPointer = (event: PointerEvent): void => { + pointerX = event.clientX; + pointerY = event.clientY; + shiftHeld = event.shiftKey; + optionHeld = event.altKey; + }; + const rememberModifiers = (event: KeyboardEvent): void => { + shiftHeld = event.shiftKey; + optionHeld = event.altKey; + }; + const forgetPointer = (): void => { + pointerX = Number.NaN; + pointerY = Number.NaN; + shiftHeld = false; + optionHeld = false; + }; + const forgetHiddenPointer = (): void => { + if (document.visibilityState === "hidden") forgetPointer(); + }; + view.addEventListener("pointermove", rememberPointer, { capture: true, passive: true }); + view.addEventListener("keydown", rememberModifiers, true); + view.addEventListener("keyup", rememberModifiers, true); + view.addEventListener("blur", forgetPointer); + document.addEventListener("visibilitychange", forgetHiddenPointer); - const rememberCurrentMap = ( - current: Extract, - revealRadius: 1 | 3, - ): void => { - const kernelSha256 = options.modelSources.kernel.sha256; - if (kernelSha256 === null) return; - const wordsFingerprint = cartographyKnowledgeWordsFingerprint(current.reachableCells.words); - const key = [ - current.epoch.mapId, - current.continent, - current.reachableCells.width, - current.reachableCells.height, - revealRadius, - current.epoch.resource, - wordsFingerprint, - ].join(":"); - if (key === requestedKnowledgeKey) return; - requestedKnowledgeKey = key; - const record = Object.freeze({ - kernelSha256, - mapId: current.epoch.mapId, - continent: current.continent, - width: current.reachableCells.width, - height: current.reachableCells.height, - revealRadius, - words: Object.freeze(Array.from(current.reachableCells.words)), + cleanup.push(() => { + view.removeEventListener("pointermove", rememberPointer, true); + view.removeEventListener("keydown", rememberModifiers, true); + view.removeEventListener("keyup", rememberModifiers, true); + view.removeEventListener("blur", forgetPointer); + document.removeEventListener("visibilitychange", forgetHiddenPointer); }); - knowledgeWrites = knowledgeWrites.then(async () => { - mapKnowledge = await options.recordMapKnowledge(record); - mapKnowledgeVersion += 1; - }).catch((cause) => { - if (requestedKnowledgeKey === key) requestedKnowledgeKey = ""; - console.error("[cartography] could not remember current map knowledge", cause); + cleanup.push(() => compassTerrainLayer.hide()); + const hideCompassLayers = (): void => { + compassTerrainLayer.hide(); + }; + const hideMission = (): void => { + options.nativeMapGraphics.hide("mission"); + options.nativeMapGraphics.hide("mission_hover"); + missionHoverLayer.hide(); + missionGridLayer.hide(); + missionTerrainLayer.hide(); + }; + const hideWorld = (): void => { + options.nativeMapGraphics.hide("world"); + options.nativeMapGraphics.hide("world_hover"); + worldHoverLayer.hide(); + worldGridLayer.hide(); + }; + const hideAllLayers = (): void => { + hideCompassLayers(); + hideMission(); + hideWorld(); + }; + const gridStats = (): CartographyGridStats => { + return Object.freeze({ + compass: compassTerrainLayer.gridSnapshot(), + missionMap: missionGridLayer.snapshot(), + worldMap: worldGridLayer.snapshot(), + }); + }; + view.gwCartographyGridStats = gridStats; + cleanup.push(() => { if (view.gwCartographyGridStats === gridStats) delete view.gwCartographyGridStats; }); + const rangeStats = (): CompassRangeStats => compassRangeLayer.snapshot(); + view.gwCompassRangeStats = rangeStats; + cleanup.push(() => { if (view.gwCompassRangeStats === rangeStats) delete view.gwCompassRangeStats; }); + const modelStats = (): CartographyModelStats => Object.freeze({ + continent: state.continent.status === "ready" + ? Object.freeze({ + status: "ready" as const, + continentId: state.continent.continent, + exploredCreditableCells: countSetBits(state.continent.exploredCreditable.words), + remainingCells: countSetBits(state.continent.remaining.words), + }) + : state.continent, + currentInstance: state.currentInstance.status === "ready" + ? Object.freeze({ + status: "ready" as const, + sequence: state.currentInstance.sequence, + mapId: state.currentInstance.epoch.mapId, + areaEpoch: state.currentInstance.epoch.area, + resourceGeneration: state.currentInstance.epoch.resource, + terrain: Object.freeze({ + width: state.currentInstance.walkableTerrain.width, + height: state.currentInstance.walkableTerrain.height, + mapUnitsPerPixel: state.currentInstance.walkableTerrain.mapUnitsPerPixel, + }), + reachableCells: countSetBits(state.currentInstance.reachableCells.words), + guidance: state.currentInstance.guidance.status === "ready" + ? Object.freeze({ + status: "ready" as const, + actionableCells: countSetBits( + state.currentInstance.guidance.actionableCells.words, + ), + }) + : state.currentInstance.guidance, + }) + : state.currentInstance, + compassReady: presentation.compass !== null, + missionMapReady: presentation.missionMap !== null + && (state.continent.status === "ready" || state.currentInstance.status === "ready"), + worldMapReady: worldGridLayer.snapshot() !== null, + worldMapObserver: options.modelSources.worldMap.diagnostics(), + kernel: options.modelSources.kernel.diagnostic(), }); - }; + view.gwCartographyModelStats = modelStats; + cleanup.push(() => { if (view.gwCartographyModelStats === modelStats) delete view.gwCartographyModelStats; }); - const render = (): void => { - const now = view.performance.now(); - if (now >= nextModelPoll) { - const previous = state; - const next = readCartographyState(options.modelSources); - if (next.continent.status === "ready") { - if ( - previous.continent.status !== "ready" - || previous.continent.generation !== next.continent.generation - || previous.continent.explorationSequence - !== next.continent.explorationSequence - ) explorationVersion += 1; + const safe = (surface: "compass" | "mission-map" | "world-map", render: () => void): void => { + try { + render(); + } catch (cause) { + if (surface === "compass") hideCompassLayers(); + else if (surface === "mission-map") hideMission(); + else hideWorld(); + console.error(`[cartography] ${surface} rendering failed`, cause); } - if (next.currentInstance.status === "ready") { - const previousGuidance = previous.currentInstance.status === "ready" - ? previous.currentInstance.guidance - : null; - const nextGuidance = next.currentInstance.guidance; + }; + + const rememberCurrentMap = ( + current: Extract, + revealRadius: 1 | 3, + ): void => { + const kernelSha256 = options.modelSources.kernel.sha256; + if (kernelSha256 === null) return; + const wordsFingerprint = cartographyKnowledgeWordsFingerprint(current.reachableCells.words); + const key = [ + current.epoch.mapId, + current.continent, + current.reachableCells.width, + current.reachableCells.height, + revealRadius, + current.epoch.resource, + wordsFingerprint, + ].join(":"); + if (key === requestedKnowledgeKey) return; + requestedKnowledgeKey = key; + const record = Object.freeze({ + kernelSha256, + mapId: current.epoch.mapId, + continent: current.continent, + width: current.reachableCells.width, + height: current.reachableCells.height, + revealRadius, + words: Object.freeze(Array.from(current.reachableCells.words)), + }); + knowledgeWrites = knowledgeWrites.then(async () => { + mapKnowledge = await options.recordMapKnowledge(record); + mapKnowledgeVersion += 1; + }).catch((cause) => { + if (requestedKnowledgeKey === key) requestedKnowledgeKey = ""; + console.error("[cartography] could not remember current map knowledge", cause); + }); + }; + + const render = (): void => { + const now = view.performance.now(); + if (now >= nextModelPoll) { + const previous = state; + const next = readCartographyState(options.modelSources); + if (next.continent.status === "ready") { + if ( + previous.continent.status !== "ready" + || previous.continent.generation !== next.continent.generation + || !bitsetsEqual(previous.continent.explored, next.continent.explored) + ) explorationVersion += 1; + } + if (next.currentInstance.status === "ready") { + const previousGuidance = previous.currentInstance.status === "ready" + ? previous.currentInstance.guidance + : null; + const nextGuidance = next.currentInstance.guidance; + if ( + previousGuidance === null + || previousGuidance.status !== nextGuidance.status + || previousGuidance.status === "ready" && nextGuidance.status === "ready" + && !bitsetsEqual( + previousGuidance.actionableCells, + nextGuidance.actionableCells, + ) + ) actionabilityVersion += 1; + } + state = next; if ( - previousGuidance === null - || previousGuidance.status !== nextGuidance.status - || previousGuidance.status === "ready" && nextGuidance.status === "ready" - && !bitsetsEqual( - previousGuidance.actionableCells, - nextGuidance.actionableCells, - ) - ) actionabilityVersion += 1; + state.continent.status === "ready" + && state.currentInstance.status === "ready" + && state.currentInstance.guidance.status === "ready" + ) { + rememberCurrentMap( + state.currentInstance, + options.settings().cartographyRevealMode === "birds-eye" ? 3 : 1, + ); + } + controls.updateQaStatus(modelStats()); + nextModelPoll = now + MODEL_POLL_MS; } - state = next; - if ( - state.continent.status === "ready" - && state.currentInstance.status === "ready" - && state.currentInstance.guidance.status === "ready" - ) { - rememberCurrentMap( - state.currentInstance, - options.settings().cartographyRevealMode === "birds-eye" ? 3 : 1, - ); + presentation = readCartographyPresentation(state, options.modelSources); + const settings = options.settings(); + const style = resolveCartographyPreset(settings.cartographyPresetLibrary); + const canvasBox = options.canvas.getBoundingClientRect(); + const compass = options.modelSources.compass.snapshot(); + const controlBox = compass === null ? null : projectNativeFrame(compass, canvasBox); + const rangesAvailable = options.nativeCompassRanges.available(); + const mapsAvailable = compassTerrainLayer.available() + || options.nativeMapGraphics.available("mission") || options.nativeMapGraphics.available("world"); + compassRangeLayer.update(rangesAvailable ? controlBox : null, visibleCompassRanges( + settings, + (id) => previewRangeOpacity.get(id) ?? null, + ), settings.compassRangeTheme); + safe("compass", () => options.nativeCompassRanges.update(compassRangeLayer.image(), compass?.generation ?? 0)); + if (controlBox === null) { + controls.hide(); + compassRangeControls.hide(); + } else if (style === null || !mapsAvailable) { + controls.hide(); + if (rangesAvailable) compassRangeControls.update(controlBox, settings, { index: 0, count: 1 }); + else compassRangeControls.hide(); + } else { + controls.update(controlBox, settings, { index: 0, count: rangesAvailable ? 2 : 1 }); + if (rangesAvailable) compassRangeControls.update(controlBox, settings, { index: 1, count: 2 }); + else compassRangeControls.hide(); } - controls.updateQaStatus(modelStats()); - nextModelPoll = now + MODEL_POLL_MS; - } - presentation = readCartographyPresentation(state, options.modelSources); - const settings = options.settings(); - const style = resolveCartographyPreset(settings.cartographyPresetLibrary); - const canvasBox = options.canvas.getBoundingClientRect(); - const compass = options.modelSources.compass.snapshot(); - const controlBox = compass === null ? null : projectNativeFrame(compass, canvasBox); - compassRangeLayer.update(controlBox, visibleCompassRanges( - settings, - (id) => previewRangeOpacity.get(id) ?? null, - ), settings.compassRangeTheme); - if (controlBox === null) { - controls.hide(); - compassRangeControls.hide(); - } else if (style === null) { - controls.hide(); - compassRangeControls.update(controlBox, settings, { index: 0, count: 1 }); - } else { - controls.update(controlBox, settings, { index: 0, count: 2 }); - compassRangeControls.update(controlBox, settings, { index: 1, count: 2 }); - } - const disposition = cartographyOverlayDisposition(state); - if (disposition !== "layers") { - terrainKey = ""; - terrain = null; - hideAllLayers(); - return; - } - if (style === null) { - hideAllLayers(); - return; - } - const gridOpacity = previewGridOpacity ?? settings.cartographyGridOpacity; - const walkabilityOpacity = previewWalkabilityOpacity - ?? settings.cartographyWalkabilityOpacity; - const revealRadius = settings.cartographyRevealMode === "birds-eye" ? 3 : 1; - const explorationKey = `${state.continent.status === "ready" - ? `${state.continent.continent}:${state.continent.generation}:${state.continent.explorationSequence}` - : "unavailable"}:${explorationVersion}`; - const nextMapKnowledgeProjectionKey = state.continent.status === "ready" - ? [ - state.continent.continent, - state.continent.explored.width, - state.continent.explored.height, - revealRadius, - mapKnowledgeVersion, - ].join(":") - : ""; - if (nextMapKnowledgeProjectionKey !== mapKnowledgeProjectionKey) { - const kernelSha256 = options.modelSources.kernel.sha256; - const words = state.continent.status === "ready" && kernelSha256 !== null - ? mergeCartographyMapKnowledge(mapKnowledge, { - kernelSha256, - continent: state.continent.continent, - width: state.continent.explored.width, - height: state.continent.explored.height, - revealRadius, - }) - : null; - rememberedReachable = state.continent.status === "ready" && words !== null - ? Object.freeze({ - width: state.continent.explored.width, - height: state.continent.explored.height, - words, - }) - : null; - mapKnowledgeProjectionKey = nextMapKnowledgeProjectionKey; - } - const actionabilityKey = [ - state.currentInstance.status === "ready" ? state.currentInstance.epoch.mapId : "-", - state.currentInstance.status === "ready" ? state.currentInstance.epoch.area : "-", - state.currentInstance.status === "ready" ? state.currentInstance.epoch.resource : "-", - actionabilityVersion, - mapKnowledgeVersion, - ].join(":"); - const nextTerrainKey = state.currentInstance.status === "ready" - ? `${state.currentInstance.epoch.area}:${state.currentInstance.epoch.resource}` - : ""; - if (nextTerrainKey !== terrainKey) { - terrain = state.currentInstance.status === "ready" - ? createWalkableTerrainSurface(document, state.currentInstance.walkableTerrain) - : null; - terrainKey = terrain === null ? "" : nextTerrainKey; - } - const isExplored = (x: number, y: number): boolean | null => { - if (state.continent.status !== "ready") return null; - return bitsetHasCell(state.continent.explored, { x, y }); - }; - const isRemaining = (x: number, y: number): boolean | null => { - if (state.continent.status !== "ready") return null; - return bitsetHasCell(state.continent.remaining, { x, y }); - }; - const canCurrentMapReveal = (x: number, y: number): boolean | null => { - if ( - state.currentInstance.status !== "ready" - || state.currentInstance.guidance.status !== "ready" - ) return null; - return bitsetHasCell(state.currentInstance.reachableCells, { x, y }); - }; - const canVisitedMapReveal = (x: number, y: number): boolean | null => { - if (rememberedReachable === null) return null; - return bitsetHasCell(rememberedReachable, { x, y }); - }; - safe("compass", () => { - if ( - state.currentInstance.status !== "ready" - || presentation.compass === null - || presentation.player === null - ) { - hideCompassLayers(); + const disposition = cartographyOverlayDisposition(state); + if (disposition !== "layers") { + terrainKey = ""; + terrain = null; + hideAllLayers(); return; } - const compass = presentation.compass; - const box = projectNativeFrame(compass, canvasBox); - if (box === null) { - hideCompassLayers(); + if (style === null) { + hideAllLayers(); return; } - const playerMapX = state.currentInstance.worldAnchor.x - + presentation.player.x / GAME_UNITS_PER_MAP_UNIT; - const playerMapY = state.currentInstance.worldAnchor.y - - presentation.player.y / GAME_UNITS_PER_MAP_UNIT; - if (settings.cartographyGridEnabled && settings.cartographyCompassGridEnabled - && state.continent.status === "ready") { - const projection = projectCartographyGridToCompass({ - frame: { generation: state.currentInstance.epoch.area, playerMapX, playerMapY }, - compass, - box, + const gridOpacity = previewGridOpacity ?? settings.cartographyGridOpacity; + const walkabilityOpacity = previewWalkabilityOpacity + ?? settings.cartographyWalkabilityOpacity; + const revealRadius = settings.cartographyRevealMode === "birds-eye" ? 3 : 1; + const explorationKey = `${state.continent.status === "ready" + ? `${state.continent.continent}:${state.continent.generation}` + : "unavailable"}:${explorationVersion}`; + const nextMapKnowledgeProjectionKey = state.continent.status === "ready" + ? [ + state.continent.continent, + state.continent.explored.width, + state.continent.explored.height, + revealRadius, + mapKnowledgeVersion, + ].join(":") + : ""; + if (nextMapKnowledgeProjectionKey !== mapKnowledgeProjectionKey) { + const kernelSha256 = options.modelSources.kernel.sha256; + const words = state.continent.status === "ready" && kernelSha256 !== null + ? mergeCartographyMapKnowledge(mapKnowledge, { + kernelSha256, + continent: state.continent.continent, + width: state.continent.explored.width, + height: state.continent.explored.height, + revealRadius, + }) + : null; + rememberedReachable = state.continent.status === "ready" && words !== null + ? Object.freeze({ + width: state.continent.explored.width, + height: state.continent.explored.height, + words, + }) + : null; + mapKnowledgeProjectionKey = nextMapKnowledgeProjectionKey; + } + const actionabilityKey = [ + state.currentInstance.status === "ready" ? state.currentInstance.epoch.mapId : "-", + state.currentInstance.status === "ready" ? state.currentInstance.epoch.area : "-", + state.currentInstance.status === "ready" ? state.currentInstance.epoch.resource : "-", + actionabilityVersion, + mapKnowledgeVersion, + ].join(":"); + const nextTerrainKey = state.currentInstance.status === "ready" + ? `${state.currentInstance.epoch.area}:${state.currentInstance.epoch.resource}` + : ""; + if (nextTerrainKey !== terrainKey) { + terrain = state.currentInstance.status === "ready" + ? createWalkableTerrainSurface(document, state.currentInstance.walkableTerrain) + : null; + terrainKey = terrain === null ? "" : nextTerrainKey; + } + const isExplored = (x: number, y: number): boolean | null => { + if (state.continent.status !== "ready") return null; + return bitsetHasCell(state.continent.explored, { x, y }); + }; + const isRemaining = (x: number, y: number): boolean | null => { + if (state.continent.status !== "ready") return null; + return bitsetHasCell(state.continent.remaining, { x, y }); + }; + const canCurrentMapReveal = (x: number, y: number): boolean | null => { + if ( + state.currentInstance.status !== "ready" + || state.currentInstance.guidance.status !== "ready" + ) return null; + return bitsetHasCell(state.currentInstance.reachableCells, { x, y }); + }; + const canVisitedMapReveal = (x: number, y: number): boolean | null => { + if (rememberedReachable === null) return null; + return bitsetHasCell(rememberedReachable, { x, y }); + }; + safe("compass", () => { + if ( + !compassTerrainLayer.available() + || state.currentInstance.status !== "ready" + || presentation.compass === null + || presentation.player === null + ) { + hideCompassLayers(); + return; + } + const compass = presentation.compass; + const box = projectNativeFrame(compass, canvasBox); + if (box === null) { + hideCompassLayers(); + return; + } + const cartography = settings.cartographyGridEnabled && settings.cartographyCompassGridEnabled + && state.continent.status === "ready" ? { + style: style.grid, opacity: gridOpacity, explorationVersion: explorationKey, + isExplored, isRemaining, revealabilityVersion: actionabilityKey, canCurrentMapReveal, canVisitedMapReveal, + } : undefined; + if ((settings.cartographyOverlayEnabled && terrain !== null) || cartography) { + compassTerrainLayer.update({ + area: state.currentInstance.epoch.area, + version: nextTerrainKey, terrain: settings.cartographyOverlayEnabled ? terrain : null, + anchorX: state.currentInstance.worldAnchor.x, + anchorY: state.currentInstance.worldAnchor.y, + scaleX: box.width / (compass.right - compass.left), + scaleY: box.height / (compass.top - compass.bottom), + style: style.walkability, + opacity: settings.cartographyOverlayEnabled ? walkabilityOpacity : 0, + ...(cartography ? {cartography, inspectionRadius: settings.cartographyRevealMode === "off" ? 0 : revealRadius} : {}), + }); + } else compassTerrainLayer.hide(); + }); + + safe("mission-map", () => { + if ( + !options.nativeMapGraphics.available("mission") + || presentation.compass === null + || presentation.missionMap === null + ) { + hideMission(); + return; + } + const mission = presentation.missionMap; + const outerBox = projectMissionMapFrame( + mission, + presentation.compass, + canvasBox, + ); + const box = outerBox === null ? null : projectMissionMapContentBox(mission, outerBox); + if (box === null) { + hideMission(); + return; + } + const mapProjection = projectCartographyGridToMissionMap({ frame: mission, box }); + const tileProjection = mapProjection === null ? null : nativeMapTileProjection(mapProjection); + if (settings.cartographyGridEnabled && state.continent.status === "ready") { + if (mapProjection === null || tileProjection === null) { + missionGridLayer.hide(); + missionHoverLayer.hide(); options.nativeMapGraphics.hide("mission_hover"); + } + else { + const hoveredCell = cartographyCellAtScreenPoint(mapProjection, pointerX, pointerY); + missionGridLayer.update({ + projection: tileProjection, + style: style.grid, + opacity: gridOpacity, + explorationVersion: explorationKey, + isExplored, + isRemaining, + revealabilityVersion: actionabilityKey, + canCurrentMapReveal, + canVisitedMapReveal, + hoveredCell: null, revealRadius: 0, + }); + if (hoveredCell && options.nativeMapGraphics.available("mission_hover")) { + const hoverProjection = nativeMapHoverProjection(mapProjection, hoveredCell, cartographyHoverRevealRadius(shiftHeld, optionHeld)); + missionHoverLayer.update({projection: hoverProjection, style: style.grid, opacity: gridOpacity, + explorationVersion: "inspection", revealabilityVersion: "inspection", isExplored, isRemaining, + canCurrentMapReveal, canVisitedMapReveal, hoveredCell, revealRadius: cartographyHoverRevealRadius(shiftHeld, optionHeld)}); + options.nativeMapGraphics.update("mission_hover", {area: mission.generation, continent: state.continent.continent, + projection: hoverProjection, images: [missionHoverLayer.image()]}); + } else { missionHoverLayer.hide(); options.nativeMapGraphics.hide("mission_hover"); } + } + } else { missionGridLayer.hide(); missionHoverLayer.hide(); options.nativeMapGraphics.hide("mission_hover"); } + if ( + settings.cartographyOverlayEnabled + && state.currentInstance.status === "ready" + && terrain !== null + ) { + const projection = tileProjection === null ? null : {box: tileProjection.box, clip: tileProjection.clip, + transform: {a: tileProjection.transform.a * terrain.mapUnitsPerPixel, b: 0, c: 0, + d: tileProjection.transform.d * terrain.mapUnitsPerPixel, + e: tileProjection.transform.e + terrain.mapLeft * tileProjection.transform.a, + f: tileProjection.transform.f + terrain.mapTop * tileProjection.transform.d}}; + if (projection === null) missionTerrainLayer.hide(); + else missionTerrainLayer.update({ + projection, + terrain, + version: nextTerrainKey, + style: style.walkability, + opacity: walkabilityOpacity, + }); + } else missionTerrainLayer.hide(); + if (tileProjection === null) options.nativeMapGraphics.hide("mission"); + else options.nativeMapGraphics.update("mission", { + area: mission.generation, continent: state.continent.status === "ready" ? state.continent.continent : 0, + projection: tileProjection, images: [missionTerrainLayer.image(), missionGridLayer.image()], }); - if (projection === null) compassGridLayer.hide(); - else compassGridLayer.update({ - projection, + }); + + safe("world-map", () => { + if ( + !options.nativeMapGraphics.available("world") + || presentation.compass === null + || presentation.worldMap === null + || state.continent.status !== "ready" + || presentation.worldMap.continent !== state.continent.continent + ) { + hideWorld(); + return; + } + const world = presentation.worldMap; + const box = projectMissionMapFrame(world, presentation.compass, canvasBox); + const mapProjection = box === null + ? null + : projectCartographyGridToWorldMap({ frame: world, box }); + if (!settings.cartographyGridEnabled || mapProjection === null) { + hideWorld(); + return; + } + const hoveredCell = cartographyCellAtScreenPoint(mapProjection, pointerX, pointerY); + const tileProjection = nativeMapTileProjection(mapProjection); + worldGridLayer.update({ + projection: tileProjection, style: style.grid, opacity: gridOpacity, explorationVersion: explorationKey, @@ -543,195 +657,37 @@ export function mountCartographyOverlay(options: Readonly<{ revealabilityVersion: actionabilityKey, canCurrentMapReveal, canVisitedMapReveal, - hoveredCell: null, - revealRadius, - }); - } else compassGridLayer.hide(); - if (settings.cartographyOverlayEnabled && terrain !== null) { - const projection = projectTerrainToCompass({ - box, - terrain, - playerMapX, - playerMapY, - directionX: compass.compassDirectionX, - directionY: compass.compassDirectionY, + hoveredCell: null, revealRadius: 0, }); - if (projection === null) compassTerrainLayer.hide(); - else compassTerrainLayer.update({ - projection, - terrain, - version: nextTerrainKey, - style: style.walkability, - opacity: walkabilityOpacity, - }); - } else compassTerrainLayer.hide(); - }); - - safe("mission-map", () => { - if ( - presentation.compass === null - || presentation.missionMap === null - ) { - hideMission(); - return; - } - const mission = presentation.missionMap; - const outerBox = projectMissionMapFrame( - mission, - presentation.compass, - canvasBox, - ); - const box = outerBox === null ? null : projectMissionMapContentBox(mission, outerBox); - if (box === null) { - hideMission(); - return; - } - const mapProjection = projectCartographyGridToMissionMap({ frame: mission, box }); - if (settings.cartographyGridEnabled && state.continent.status === "ready") { - if (mapProjection === null) { - missionGridLayer.hide(); - missionCoverageLayer.hide(); - missionBoundaryLayer.hide(); - } - else { - const hoveredCell = shiftHeld - ? cartographyCellAtScreenPoint(mapProjection, pointerX, pointerY) - : null; - missionGridLayer.update({ - projection: mapProjection, - style: style.grid, - opacity: gridOpacity, - explorationVersion: explorationKey, - isExplored, - isRemaining, - revealabilityVersion: actionabilityKey, - canCurrentMapReveal, - canVisitedMapReveal, - hoveredCell, - revealRadius: hoveredCell === null - ? revealRadius - : cartographyHoverRevealRadius(shiftHeld, optionHeld), - }); - missionCoverageLayer.update({ - projection: mapProjection, - explored: state.continent.exploredCreditable, - version: explorationKey, - }); - if (state.currentInstance.status === "ready") { - missionBoundaryLayer.update({ - projection: mapProjection, - bounds: state.currentInstance.mapBounds, - version: actionabilityKey, - }); - } else missionBoundaryLayer.hide(); - } - } else missionGridLayer.hide(); - if (!settings.cartographyGridEnabled || state.continent.status !== "ready") { - missionCoverageLayer.hide(); - missionBoundaryLayer.hide(); - } - if ( - settings.cartographyOverlayEnabled - && state.currentInstance.status === "ready" - && terrain !== null - ) { - const projection = projectTerrainToMissionMap({ frame: mission, box, terrain }); - if (projection === null) missionTerrainLayer.hide(); - else missionTerrainLayer.update({ - projection, - terrain, - version: nextTerrainKey, - style: style.walkability, - opacity: walkabilityOpacity, + options.nativeMapGraphics.update("world", { area: world.generation, continent: world.continent, + projection: tileProjection, images: [worldGridLayer.image()], }); - } else missionTerrainLayer.hide(); - }); - - safe("world-map", () => { - if ( - presentation.compass === null - || presentation.worldMap === null - || state.continent.status !== "ready" - || presentation.worldMap.continent !== state.continent.continent - ) { - hideWorld(); - return; - } - const world = presentation.worldMap; - const box = projectMissionMapFrame(world, presentation.compass, canvasBox); - const mapProjection = box === null - ? null - : projectCartographyGridToWorldMap({ frame: world, box }); - if (!settings.cartographyGridEnabled || mapProjection === null) { - hideWorld(); - return; - } - const hoveredCell = shiftHeld - ? cartographyCellAtScreenPoint(mapProjection, pointerX, pointerY) - : null; - worldGridLayer.update({ - projection: mapProjection, - style: style.grid, - opacity: gridOpacity, - explorationVersion: explorationKey, - isExplored, - isRemaining, - revealabilityVersion: actionabilityKey, - canCurrentMapReveal, - canVisitedMapReveal, - hoveredCell, - revealRadius: hoveredCell === null - ? 0 - : cartographyHoverRevealRadius(shiftHeld, optionHeld), + if (hoveredCell && options.nativeMapGraphics.available("world_hover")) { + const hoverProjection = nativeMapHoverProjection(mapProjection, hoveredCell, cartographyHoverRevealRadius(shiftHeld, optionHeld)); + worldHoverLayer.update({projection: hoverProjection, style: style.grid, opacity: gridOpacity, + explorationVersion: "inspection", revealabilityVersion: "inspection", isExplored, isRemaining, + canCurrentMapReveal, canVisitedMapReveal, hoveredCell, revealRadius: cartographyHoverRevealRadius(shiftHeld, optionHeld)}); + options.nativeMapGraphics.update("world_hover", {area: world.generation, continent: world.continent, + projection: hoverProjection, images: [worldHoverLayer.image()]}); + } else { worldHoverLayer.hide(); options.nativeMapGraphics.hide("world_hover"); } }); - worldCoverageLayer.update({ - projection: mapProjection, - explored: state.continent.exploredCreditable, - version: explorationKey, - }); - if (state.currentInstance.status === "ready") { - worldBoundaryLayer.update({ - projection: mapProjection, - bounds: state.currentInstance.mapBounds, - version: actionabilityKey, - }); - } else worldBoundaryLayer.hide(); - }); - }; + }; - const update = (): void => { - if (disposed) return; - render(); - if (!disposed) animationFrame = view.requestAnimationFrame(update); - }; - startupTimer = view.setTimeout(() => { - startupTimer = 0; - if (!disposed) animationFrame = view.requestAnimationFrame(update); - }); + const update = (): void => { + if (disposed) return; + render(); + if (!disposed) animationFrame = view.requestAnimationFrame(update); + }; + startupTimer = view.setTimeout(() => { + startupTimer = 0; + if (!disposed) animationFrame = view.requestAnimationFrame(update); + }); - return () => { - disposed = true; - if (startupTimer !== 0) view.clearTimeout(startupTimer); - view.cancelAnimationFrame(animationFrame); - view.removeEventListener("pointermove", rememberPointer, true); - view.removeEventListener("keydown", rememberModifiers, true); - view.removeEventListener("keyup", rememberModifiers, true); - view.removeEventListener("blur", forgetPointer); - document.removeEventListener("visibilitychange", forgetHiddenPointer); - compassTerrainLayer.dispose(); - missionTerrainLayer.dispose(); - missionCoverageLayer.dispose(); - missionBoundaryLayer.dispose(); - worldCoverageLayer.dispose(); - worldBoundaryLayer.dispose(); - compassGridLayer.dispose(); - missionGridLayer.dispose(); - worldGridLayer.dispose(); - compassRangeLayer.dispose(); - compassRangeControls.dispose(); - controls.dispose(); - if (view.gwCartographyGridStats === gridStats) delete view.gwCartographyGridStats; - if (view.gwCartographyModelStats === modelStats) delete view.gwCartographyModelStats; - if (view.gwCompassRangeStats === rangeStats) delete view.gwCompassRangeStats; - }; + return dispose; + } catch (cause) { + try { dispose(); } catch (cleanupError) { + throw new AggregateError([cause, cleanupError], "Maps presentation and cleanup failed", {cause: cleanupError}); + } + throw cause; + } } diff --git a/src/renderer/gw-native.d.ts b/src/renderer/gw-native.d.ts index 47a08fa7..53dd896a 100644 --- a/src/renderer/gw-native.d.ts +++ b/src/renderer/gw-native.d.ts @@ -276,6 +276,8 @@ declare global { } interface Window { + gwNativeMapGraphicsStats?: () => Readonly | null>>; readonly gwNative: RendererGwNativeApi; FS?: { syncfs( @@ -289,6 +291,7 @@ declare global { gwApplySettings?(settings: AppSettings): void; gwCartographyGridStats?(): CartographyGridStats; gwCompassRangeStats?(): CompassRangeStats; + gwNativeCompassTerrainStats?(): Readonly<{ status: "hidden" | "ready" | "unavailable"; area: number; updates: number; uploads: number; created: number; destroyed: number; textureSize: number }>; /** Pointer-free atomic epoch and classification diagnostics; development only. */ gwCartographyModelStats?(): CartographyModelStats; gwSurfaces: GwonmacSurfaceController; diff --git a/src/shared/native-compass-terrain.ts b/src/shared/native-compass-terrain.ts new file mode 100644 index 00000000..0cb3b13e --- /dev/null +++ b/src/shared/native-compass-terrain.ts @@ -0,0 +1,10 @@ +/** + * Defines the bounded bitmap contract between Cartography and its native Canvas. + * Coordinates are game-world units; native pointers stay inside the private loader. + */ +export const NATIVE_COMPASS_TERRAIN_MAGIC = 0x43544d50; +export const NATIVE_COMPASS_TERRAIN_HEADER_BYTES = 32; +export const NATIVE_COMPASS_TERRAIN_MAX_SIZE = 2048; +export const NATIVE_COMPASS_TERRAIN_WORLD_SPAN = 16384; +export const NATIVE_COMPASS_WORLD_SCALE = 0.40625 / 4500; +export const NATIVE_COMPASS_CLIP_RADIUS = 96 / 245; diff --git a/src/shared/native-map-graphics.ts b/src/shared/native-map-graphics.ts new file mode 100644 index 00000000..0ec68651 --- /dev/null +++ b/src/shared/native-map-graphics.ts @@ -0,0 +1,9 @@ +/** + * Bounds the copied Cartography bitmap accepted by each native map owner. + * The private loader and certified native publisher share this one contract. + */ +export const NATIVE_MAP_GRAPHICS_MAGIC = 0x4d415047; +export const NATIVE_MAP_GRAPHICS_HEADER_BYTES = 64; +export const NATIVE_MAP_GRAPHICS_MAX_SIZE = 2048; +export const NATIVE_MAP_GRAPHICS_SURFACES = ["mission", "world", "mission_hover", "world_hover"] as const; +export type NativeMapGraphicsSurface = typeof NATIVE_MAP_GRAPHICS_SURFACES[number]; diff --git a/tests/client-artifact/native-compass-terrain.test.ts b/tests/client-artifact/native-compass-terrain.test.ts new file mode 100644 index 00000000..70280a7a --- /dev/null +++ b/tests/client-artifact/native-compass-terrain.test.ts @@ -0,0 +1,118 @@ +/** Executes emitted native terrain ownership and upload code against controlled peers. */ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { transformCartographySpikeWasm } from "../../src/main/certification/pathing-spike-transform.js"; +import { wasmEvidence } from "../../src/main/certification/wasm-evidence.js"; +import { concat, encodeCode, encodeSection, parseCode, parseExports, sectionById, splitSections, uleb, WASM_HEADER } from "../../src/main/core/wasm-binary.js"; +import { encodeName } from "../../src/main/certification/cartography-transform-internals.js"; +import { NATIVE_COMPASS_CLIP_RADIUS, NATIVE_COMPASS_TERRAIN_MAGIC, NATIVE_COMPASS_WORLD_SCALE } from "../../src/shared/native-compass-terrain.js"; + +test("native terrain owns bounded resources, follows its Canvas and refuses invalid uploads", async () => { + assert.ok(process.env.GW_CLIENT_WASM); + const input = new Uint8Array(await readFile(process.env.GW_CLIENT_WASM)); + const output = transformCartographySpikeWasm(input, "relocated"); + const sections = splitSections(output); const bodies = parseCode(sectionById(sections, 10)); + const evidence = wasmEvidence(output); assert.ok(evidence); const module = evidence.moduleView(); + const exported = parseExports(sectionById(sections, 7)); + for (const surface of ["terrain", "ranges"] as const) { + const prefix = `gwonmac_compass_${surface}`; + const publishIndex = exported.find((entry) => entry.name === `${prefix}_publish`)?.index; + assert.ok(publishIndex !== undefined); + const first = publishIndex - 5; + const selected = Array.from({length: 6}, (_, index) => first + index); + const peers = [1444, 1445, 1446, 1448, 1449, 1556, 1565, 1579, 748, 6827, 2249, 3137, 1569]; + const indices = new Map([...peers, ...selected].map((index, position) => [index, position])); + const decoded = evidence.decodeFunctions([]); + const rewritten = selected.map((index) => { + const body = bodies[index - module.functionImportCount]!; + const sites = decoded.find((row) => row.functionIndex === index)?.callSites; assert.ok(sites); + const edits = [...sites].flatMap(([target, calls]) => { + const replacement = indices.get(target); assert.ok(replacement !== undefined, `unexpected peer ${target}`); + return calls.map((site) => ({...site, replacement})); + }).sort((a, b) => a.offset - b.offset); + let cursor = 0; const parts: Uint8Array[] = []; + for (const edit of edits) { parts.push(body.slice(cursor, edit.offset + 1), uleb(edit.replacement)); cursor = edit.operandEnd; } + return concat(...parts, body.slice(cursor)); + }); + const globals = exported.filter((entry) => entry.kind === 3 && (entry.name.startsWith(`${prefix}_`) + || ["gwonmac_cartography_context_area_epoch", "gwonmac_cartography_context_status"].includes(entry.name))); + const section = (id: number, body: Uint8Array) => encodeSection({id, body}); + const labels = ["hide", "destroy", "init", "attach", "update", "publish"]; + const fixture = concat(WASM_HEADER, + section(1, sectionById(sections, 1)), + section(2, concat(uleb(peers.length), ...peers.map((index) => concat(encodeName("peer"), encodeName(String(index)), Uint8Array.of(0), uleb(module.functionTypeIndices[index]!))))), + section(3, concat(uleb(selected.length), ...selected.map((index) => uleb(module.functionTypeIndices[index]!)))), + section(5, Uint8Array.of(1, 0, 4)), section(6, sectionById(sections, 6)), + section(7, concat(uleb(labels.length + globals.length + 2), + ...labels.map((name, index) => concat(encodeName(name), Uint8Array.of(0), uleb(peers.length + index))), + encodeName("memory"), Uint8Array.of(2, 0), encodeName("stack"), Uint8Array.of(3, 0), + ...globals.map((entry) => concat(encodeName(entry.name), Uint8Array.of(3), uleb(entry.index))))), + section(10, encodeCode(rewritten))); + const released: number[] = []; const vertices: number[] = []; const indicesAllocated: number[] = []; + let textureCreates = 0; let attachments = 0; + const { exports } = new WebAssembly.Instance(new WebAssembly.Module(Uint8Array.from(fixture)), {peer: { + 1444: (format: number, flags: number) => { assert.deepEqual([format, flags], [265, 0]); return 11; }, + 1445: (handle: number, count: number) => { assert.equal(handle, 11); indicesAllocated.push(count); return 131072; }, + 1446: (handle: number, count: number) => { assert.equal(handle, 11); vertices.push(count); return 65536; }, + 1448: (handle: number) => { assert.equal(handle, 11); }, + 1449: (handle: number, min: number, max: number) => { assert.equal(handle, 11); assert.equal(view.getFloat32(min, true), 0); assert.ok(view.getFloat32(max, true) > 0); }, + 1556: (handle: number) => { assert.equal(handle, 7); return 12; }, + 1565: (draw: number, index: number, buffer: number) => { assert.deepEqual([draw, index, buffer], [12, 0, 11]); }, + 1579: (draw: number, index: number) => { assert.deepEqual([draw, index], [12, 0]); }, + 748: (handle: number) => { released.push(handle); }, + 6827: (frame: number, count: number, pointer: number, slot: number) => { assert.deepEqual([frame, count, view.getUint32(pointer, true), slot], [43, 1, 12, 4]); attachments += 1; }, + 2249: (mips: number, format: number, dimensions: number, levels: number, flags: number) => { + assert.deepEqual([view.getUint32(mips, true), format, view.getUint32(dimensions, true), view.getUint32(dimensions + 4, true), levels, flags], [2080, 0, 64, 64, 1, 112]); + textureCreates += 1; return 21; + }, + 3137: (count: number, texture: number, sampler: number, shader: number, a: number, b: number, flags: number, mode: number) => { + assert.deepEqual([count, view.getUint32(texture, true), view.getUint32(sampler, true), view.getUint32(shader, true), a, b, flags, mode], [1, 21, surface === "ranges" ? 4 : 7, 482, 0, 0, 33555424, surface === "ranges" ? 4 : 11]); return 22; + }, + 1569: (draw: number, index: number, material: number) => { assert.deepEqual([draw, index, material], [12, 0, 22]); }, + }}); + assert.ok(exports.memory instanceof WebAssembly.Memory); const view = new DataView(exports.memory.buffer); + const scalar = (name: string, value: number) => { const target = exports[name]; assert.ok(target instanceof WebAssembly.Global); target.value = value; }; + const invoke = (name: string, ...args: number[]) => { const target = exports[name]; assert.equal(typeof target, "function"); if (typeof target === "function") return target(...args); }; + scalar("stack", 196608); scalar("gwonmac_cartography_context_status", 1); scalar("gwonmac_cartography_context_area_epoch", 7); + const owner = 256; const camera = 768; const direction = 784; const region = 2048; const bytes = 32 + 64 * 64 * 4; + const rectangle = (width: number, height: number) => { + [17, 29, 17 + width, 29 + height].forEach((value, index) => view.setFloat32(owner + 172 + index * 4, value, true)); + }; + view.setUint32(owner + 144, 7, true); rectangle(245, 245); view.setFloat32(direction, 0, true); view.setFloat32(direction + 4, 1, true); + const header = () => { + [NATIVE_COMPASS_TERRAIN_MAGIC, bytes, 7, 64].forEach((value, index) => view.setUint32(region + index * 4, value, true)); + [-8192, 8192, 16384].forEach((value, index) => view.setFloat32(region + 16 + index * 4, value, true)); + view.setUint32(region + 28, 1, true); + }; + header(); assert.equal(invoke("publish", region, bytes), 0); assert.equal(textureCreates, 0); + invoke("init", owner); invoke("attach", owner, 43); assert.equal(attachments, 1); + assert.equal(invoke("publish", region, bytes), 1); assert.deepEqual(released.splice(0), [22, 21]); + invoke("update", owner, camera, direction); assert.deepEqual(vertices.splice(0), [129]); assert.equal(indicesAllocated.at(-1), 576); + assert.equal(view.getFloat32(65536, true), 122.5); assert.equal(view.getFloat32(65540, true), 122.5); + assert.equal(view.getFloat32(65552, true), 0.5); assert.equal(view.getFloat32(65556, true), 0.5); + assert.ok(Math.abs(view.getFloat32(65536 + 24, true) - (122.5 + 245 * NATIVE_COMPASS_CLIP_RADIUS - 1)) < 0.001); + assert.ok(Math.abs(view.getFloat32(65552 + 24, true) - (0.5 + (NATIVE_COMPASS_CLIP_RADIUS - 1 / 245) / (surface === "ranges" ? 1 : NATIVE_COMPASS_WORLD_SCALE * 16384))) < 0.00001); + for (let index = 0; index < 576; index += 1) assert.ok(view.getUint16(131072 + index * 2, true) < 129); + for (const [width, height] of [[384, 384], [320, 192]] as const) { + rectangle(width, height); invoke("update", owner, camera, direction); + assert.equal(view.getFloat32(65536, true), width / 2); assert.equal(view.getFloat32(65540, true), height / 2); + } + assert.equal(textureCreates, 1, "camera and rectangle updates do not allocate textures"); + const allocationsBeforeIdle = indicesAllocated.length; + invoke("update", owner, camera, direction); invoke("update", owner, camera, direction); + assert.equal(indicesAllocated.length, allocationsBeforeIdle, "idle native frames do not rewrite mesh buffers"); + view.setFloat32(camera, 100, true); invoke("update", owner, camera, direction); + assert.equal(indicesAllocated.length, allocationsBeforeIdle + (surface === "ranges" ? 0 : 1), + "only world-anchored terrain responds to camera movement"); + for (const [offset, value] of [[0, 0], [8, 6], [12, 63], [12, 4096], [28, 0]] as const) { + header(); view.setUint32(region + offset, value, true); assert.equal(invoke("publish", region, bytes), 0); + } + assert.equal(invoke("publish", 262140, bytes), 0); assert.equal(invoke("publish", -1, bytes), 0); + assert.equal(textureCreates, 1); + header(); assert.equal(invoke("publish", region, bytes), 1); released.length = 0; + scalar("gwonmac_cartography_context_area_epoch", 8); invoke("update", owner, camera, direction); + assert.equal(indicesAllocated.at(-1), 0); + invoke("destroy", owner); invoke("destroy", owner); assert.deepEqual(released, [12, 11]); + } +}); diff --git a/tests/client-artifact/native-map-graphics.test.ts b/tests/client-artifact/native-map-graphics.test.ts new file mode 100644 index 00000000..f8ccd4bf --- /dev/null +++ b/tests/client-artifact/native-map-graphics.test.ts @@ -0,0 +1,105 @@ +/** Executes native map draw ownership, matrix restoration and bitmap refusal. */ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { transformCartographySpikeWasm } from "../../src/main/certification/pathing-spike-transform.js"; +import { wasmEvidence } from "../../src/main/certification/wasm-evidence.js"; +import { concat, encodeCode, encodeSection, parseCode, parseExports, sectionById, splitSections, uleb, WASM_HEADER } from "../../src/main/core/wasm-binary.js"; +import { encodeName } from "../../src/main/certification/cartography-transform-internals.js"; +import { NATIVE_MAP_GRAPHICS_MAGIC } from "../../src/shared/native-map-graphics.js"; + +test("each native map owns its mesh, restores matrices and refuses stale or malformed textures", async () => { + assert.ok(process.env.GW_CLIENT_WASM); + const input = new Uint8Array(await readFile(process.env.GW_CLIENT_WASM)); + const output = transformCartographySpikeWasm(input, "relocated"); + const sections = splitSections(output); const bodies = parseCode(sectionById(sections, 10)); + const evidence = wasmEvidence(output); assert.ok(evidence); const module = evidence.moduleView(); + const exported = parseExports(sectionById(sections, 7)); const decoded = evidence.decodeFunctions([]); + for (const surface of ["mission", "world", "mission_hover", "world_hover"] as const) { + const publishIndex = exported.find((entry) => entry.name === `gwonmac_${surface}_graphics_publish`)?.index; + assert.ok(publishIndex !== undefined); + const selected = Array.from({length: 4}, (_, index) => publishIndex - 3 + index); + const peers = [1444, 1445, 1446, 1448, 1449, 1554, 1564, 1569, 1579, 1333, 1334, 1357, 264, 2956, 2249, 3137, 748]; + const indices = new Map([...peers, ...selected].map((index, position) => [index, position])); + const rewritten = selected.map((index) => { + const body = bodies[index - module.functionImportCount]!; + const sites = decoded.find((row) => row.functionIndex === index)?.callSites; assert.ok(sites); + const edits = [...sites].flatMap(([target, calls]) => { + const replacement = indices.get(target); assert.ok(replacement !== undefined, `unexpected native peer ${target}`); + return calls.map((site) => ({...site, replacement})); + }).sort((a, b) => a.offset - b.offset); + let cursor = 0; const parts: Uint8Array[] = []; + for (const edit of edits) { parts.push(body.slice(cursor, edit.offset + 1), uleb(edit.replacement)); cursor = edit.operandEnd; } + return concat(...parts, body.slice(cursor)); + }); + const globals = exported.filter((entry) => entry.kind === 3 && (entry.name.startsWith(`gwonmac_${surface}_graphics_`) + || ["gwonmac_cartography_context_area_epoch", "gwonmac_cartography_context_status"].includes(entry.name))); + const section = (id: number, body: Uint8Array) => encodeSection({id, body}); + const labels = ["hide", "destroy", "render", "publish"]; + const fixture = concat(WASM_HEADER, section(1, sectionById(sections, 1)), + section(2, concat(uleb(peers.length), ...peers.map((index) => concat(encodeName("peer"), encodeName(String(index)), Uint8Array.of(0), uleb(module.functionTypeIndices[index]!))))), + section(3, concat(uleb(selected.length), ...selected.map((index) => uleb(module.functionTypeIndices[index]!)))), + section(5, Uint8Array.of(1, 0, 4)), section(6, sectionById(sections, 6)), + section(7, concat(uleb(labels.length + globals.length + 2), + ...labels.map((name, index) => concat(encodeName(name), Uint8Array.of(0), uleb(peers.length + index))), + encodeName("memory"), Uint8Array.of(2, 0), encodeName("stack"), Uint8Array.of(3, 0), + ...globals.map((entry) => concat(encodeName(entry.name), Uint8Array.of(3), uleb(entry.index))))), + section(10, encodeCode(rewritten))); + const released: number[] = []; const matrixEvents: string[] = []; + let textures = 0; let meshes = 0; let draws = 0; + const { exports } = new WebAssembly.Instance(new WebAssembly.Module(Uint8Array.from(fixture)), {peer: { + 1444: (format: number, flags: number) => { assert.deepEqual([format, flags], [265, 0]); meshes += 1; return 11; }, + 1445: (handle: number, count: number) => { assert.deepEqual([handle, count], [11, 6]); return 131072; }, + 1446: (handle: number, count: number) => { assert.deepEqual([handle, count], [11, 4]); return 65536; }, + 1448: (handle: number) => { assert.equal(handle, 11); }, 1449: () => {}, + 1554: (count: number, buffer: number, material: number, flags: number, extra: number) => { + assert.deepEqual([count, view.getUint32(buffer, true), view.getUint32(material, true), flags, extra], [1, 11, 22, 0, 0]); return 12; + }, + 1564: (count: number, pointer: number) => { assert.deepEqual([count, view.getUint32(pointer, true)], [1, 12]); matrixEvents.push("capture-view"); }, + 1569: (draw: number, index: number, material: number) => { assert.deepEqual([draw, index, material], [12, 0, 22]); }, + 1579: (draw: number, index: number) => { assert.deepEqual([draw, index], [12, 0]); matrixEvents.push("capture-model"); }, + 1333: (matrix: number) => { assert.equal(matrix, 2); matrixEvents.push("save"); return 1024; }, + 1334: (matrix: number, pointer: number) => { assert.equal(matrix, 2); assert.equal(view.getFloat32(pointer, true), 17); matrixEvents.push("restore"); }, + 1357: (matrix: number) => { assert.equal(matrix, 2); matrixEvents.push("identity"); }, + 264: (to: number, from: number, bytes: number) => { assert.equal(bytes, 52); new Uint8Array(view.buffer).copyWithin(to, from, from + bytes); }, + 2956: (pass: number, count: number, pointer: number, flags: number) => { assert.deepEqual([pass, count, view.getUint32(pointer, true), flags], [0, 1, 12, 0]); draws += 1; matrixEvents.push("draw"); }, + 2249: (mips: number, format: number, dimensions: number, levels: number, flags: number) => { + assert.deepEqual([view.getUint32(mips, true), format, view.getUint32(dimensions, true), view.getUint32(dimensions + 4, true), levels, flags], [2112, 0, 64, 64, 1, 112]); textures += 1; return 21; + }, + 3137: () => 22, 748: (handle: number) => { released.push(handle); }, + }}); + const memory = exports.memory; assert.ok(memory instanceof WebAssembly.Memory); const view = new DataView(memory.buffer); + const scalar = (name: string, value: number) => { const target = exports[name]; assert.ok(target instanceof WebAssembly.Global); target.value = value; }; + const invoke = (name: string, ...args: number[]) => { const target = exports[name]; assert.equal(typeof target, "function"); if (typeof target === "function") return target(...args); }; + scalar("stack", 196608); scalar("gwonmac_cartography_context_status", 1); scalar("gwonmac_cartography_context_area_epoch", 7); + const owner = 256; const region = 2048; const bytes = 64 + 64 * 64 * 4; + view.setFloat32(1024, 17, true); view.setUint32(owner + 4, 2, true); + const header = () => { + [NATIVE_MAP_GRAPHICS_MAGIC, bytes, 7, 64, 64, 1, 2, 0].forEach((value, index) => view.setUint32(region + index * 4, value, true)); + [100, 200, 300, 200, 300, 400, 100, 400].forEach((value, index) => view.setFloat32(region + 32 + index * 4, value, true)); + }; + header(); assert.equal(invoke("publish", region, bytes), 0, "no owner before native draw event"); + invoke("render", owner); assert.equal(draws, 0); + assert.equal(invoke("publish", region, bytes), 1); assert.deepEqual(released.splice(0), [22, 21]); + invoke("render", owner); assert.equal(draws, 1); + assert.deepEqual(matrixEvents.splice(0), ["save", "identity", "capture-model", "capture-view", "draw", "restore"]); + assert.equal(view.getFloat32(65536, true), 100); assert.equal(view.getFloat32(65540, true), -200); + invoke("render", owner); assert.equal(textures, 1, "native draws reuse the texture"); + header(); assert.equal(invoke("publish", region, bytes), 1); assert.equal(meshes, 1); + for (const [offset, value] of [[0, 0], [8, 6], [12, 63], [16, 4096], [20, 0]] as const) { + header(); view.setUint32(region + offset, value, true); assert.equal(invoke("publish", region, bytes), 0); + } + header(); view.setFloat32(region + 32, NaN, true); assert.equal(invoke("publish", region, bytes), 0); + assert.equal(invoke("publish", 262140, bytes), 0); assert.equal(invoke("publish", -1, bytes), 0); + assert.equal(textures, 2); + header(); assert.equal(invoke("publish", region, bytes), 1); + const before = draws; + if (surface.startsWith("world")) { + view.setUint32(owner + 4, 3, true); invoke("render", owner); assert.equal(draws, before, "continent change withdraws old pixels"); + assert.equal(invoke("publish", region, bytes), 0, "old continent cannot publish"); + } else { + scalar("gwonmac_cartography_context_area_epoch", 8); invoke("render", owner); assert.equal(draws, before); + } + released.length = 0; invoke("destroy", owner); invoke("destroy", owner); assert.deepEqual(released, [12, 11]); + } +}); diff --git a/tests/electron/launcher-feature-controls.spec.ts b/tests/electron/launcher-feature-controls.spec.ts index 6fdba775..c044e5e7 100644 --- a/tests/electron/launcher-feature-controls.spec.ts +++ b/tests/electron/launcher-feature-controls.spec.ts @@ -86,7 +86,7 @@ test("existing custom map styles can be selected, edited and extended", async () }); await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Maps", exact: true }).click(); - const compass = page.getByRole("checkbox", { name: /Grid on Compass/ }); + const compass = page.getByRole("checkbox", { name: /Markers on Compass/ }); await expect(compass).not.toBeChecked(); await expect(compass).toBeDisabled(); await page.getByRole("checkbox", { name: /Exploration grid/ }).check(); @@ -144,7 +144,7 @@ test("map styles offer usable color and numeric controls at normal size and 200% (await window.launcherNative.state.get()).settings.cartographyPresetLibrary.customPresets[0]?.style.walkability.boundaryWidth, )).toBe(0); - await page.getByText("Advanced grid lines", { exact: true }).click(); + await page.getByText("Inspection outlines", { exact: true }).click(); for (const size of [ { width: 1180, height: 900, zoom: 1 }, { width: 900, height: 700, zoom: 1 }, diff --git a/tests/unit/cartography-lifecycle.test.ts b/tests/unit/cartography-lifecycle.test.ts index 5b13346e..d7c02dc1 100644 --- a/tests/unit/cartography-lifecycle.test.ts +++ b/tests/unit/cartography-lifecycle.test.ts @@ -51,3 +51,57 @@ test("a failed optional installation reports failure without retrying forever", assert.equal(failures.length, 1); host.dispose(); }); + + +test("failed Maps constructors release earlier native surfaces and detached painters", async (t) => { + const {installCartographySpike} = await import("../../src/renderer/cartography-spike/index.js"); + const {CARTOGRAPHY_CONTEXT_GLOBALS, CARTOGRAPHY_CONTEXT_SCALARS, + EXPLORATION_SPIKE_GLOBALS, EXPLORATION_SPIKE_SCALARS, + WORLD_MAP_ANCHOR_SPIKE_GLOBALS, WORLD_MAP_ANCHOR_SPIKE_SCALARS} = await import("../../src/shared/cartography-spike.js"); + t.mock.method(console, "error", () => {}); + for (const failAt of ["native", "overlay"]) { + const view = new EventTarget(); + const add = t.mock.method(view, "addEventListener"); + const remove = t.mock.method(view, "removeEventListener"); + const oldWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", {configurable: true, value: view}); + try { + const canvases: {width: number; height: number}[] = []; + let hides = 0; + const exports = Object.fromEntries([ + ...CARTOGRAPHY_CONTEXT_SCALARS, ...EXPLORATION_SPIKE_SCALARS, ...WORLD_MAP_ANCHOR_SPIKE_SCALARS, + ].map(name => [name, new WebAssembly.Global({value: "i32"})])); + Object.assign(exports, { + [CARTOGRAPHY_CONTEXT_GLOBALS.observe]: () => {}, + [EXPLORATION_SPIKE_GLOBALS.observe]: () => {}, + [EXPLORATION_SPIKE_GLOBALS.readWord]: () => 0, + [WORLD_MAP_ANCHOR_SPIKE_GLOBALS.observe]: () => {}, + gwonmac_compass_ranges_hide: () => { hides++; }, + gwonmac_mission_graphics_publish: () => 0, + gwonmac_mission_graphics_hide: () => {}, + gwonmac_mission_graphics_serial: new WebAssembly.Global({value: "i32"}), + }); + const document = {defaultView: view, createElement(tag: string) { + if (tag !== "canvas" || failAt === "native" && canvases.length === 1) throw new Error("canvas allocation failed"); + const canvas = {width: 128, height: 128, getContext: () => ({})}; + canvases.push(canvas); return canvas; + }}; + // Deliberately incomplete DOM peer: construction fails before any game observation. + await assert.rejects(installCartographySpike({exports, + parent: {ownerDocument: document} as unknown as HTMLElement, canvas: {} as HTMLCanvasElement, + settings: () => { throw new Error("must not read settings"); }, + persist: async () => { throw new Error("must not save"); }, + exportEvidence: async () => { throw new Error("must not export"); }, + getMapKnowledge: async () => [], recordMapKnowledge: async () => [], + }), /canvas allocation failed/); + assert.equal(hides, 1, "an earlier native range owner is released on either failure"); + assert.ok(canvases.every(canvas => canvas.width === 0 && canvas.height === 0)); + assert.equal(add.mock.callCount(), remove.mock.callCount(), "no graphics-reset listener survives refusal"); + assert.equal(window.gwExplorationSpike, undefined); + assert.equal(window.gwWorldMapAnchorSpike, undefined); + } finally { + if (oldWindow) Object.defineProperty(globalThis, "window", oldWindow); + else Reflect.deleteProperty(globalThis, "window"); + } + } +}); diff --git a/tests/unit/cartography-model.test.ts b/tests/unit/cartography-model.test.ts index 0f01b380..e40a7fd2 100644 --- a/tests/unit/cartography-model.test.ts +++ b/tests/unit/cartography-model.test.ts @@ -360,3 +360,22 @@ test("refreshes frame-sensitive presentation without rerunning classification", const presentation = readCartographyPresentation(state, current); assert.deepEqual(presentation.player, { x: 99, y: 20 }); }); + +test("unchanged exploration reads reuse continent masks; a changed bit invalidates them", () => { + const original = sources(); + let sequence = 1; + let words = new Uint32Array(Math.ceil(WIDTH * HEIGHT / 32)); + const exploration: ExplorationSpikeController = { + ...original.exploration, + readBitmap: () => ({ snapshot: {status: 1, sequence: sequence++, generation: AREA_EPOCH, width: WIDTH, height: HEIGHT, dwordCount: words.length}, words: words.slice() }), + }; + const input = {...original, exploration}; + const first = readCartographyState(input); const unchanged = readCartographyState(input); + assert.equal(first.continent.status, "ready"); + assert.equal(unchanged.continent, first.continent, "a polling counter does not invalidate map artwork"); + words = Uint32Array.from(wordsWith(CREDITABLE_CELL)); + const changed = readCartographyState(input); + assert.notEqual(changed.continent, first.continent); + assert.equal(changed.continent.status, "ready"); + if (changed.continent.status === "ready") assert.equal(bitsetHasCell(changed.continent.remaining, CREDITABLE_CELL), false); +}); diff --git a/tests/unit/cartography-overlay.test.ts b/tests/unit/cartography-overlay.test.ts index 132c8560..2330cfe5 100644 --- a/tests/unit/cartography-overlay.test.ts +++ b/tests/unit/cartography-overlay.test.ts @@ -3,7 +3,6 @@ import test from "node:test"; import { projectGamePointToMissionMap, projectMissionMapContentBox, - projectTerrainToCompass, projectTerrainToMissionMap, } from "../../src/renderer/cartography-spike/map-projections.js"; @@ -33,43 +32,6 @@ test("projects the certified Mission Map drawable area and player anchor", () => ); }); -test("projects one cached terrain raster through Compass rotation", () => { - const projection = projectTerrainToCompass({ - box: { left: 10, top: 20, width: 245, height: 260 }, - terrain: { - canvas: {} as HTMLCanvasElement, - mapLeft: 0, - mapTop: 100, - mapUnitsPerPixel: 0.5, - }, - playerMapX: 0, - playerMapY: 0, - directionX: 0, - directionY: 1, - }); - assert.ok(projection); - assert.equal(projection.clip.kind, "circle"); - assert.ok(Math.abs(projection.transform.a - 0.9216) < 0.0001); - assert.ok(Math.abs(projection.transform.b) === 0); - assert.equal(projection.transform.c, 0); - assert.ok(Math.abs(projection.transform.d - 0.9216) < 0.0001); - assert.equal(projection.transform.e, 122.5); - assert.ok(Math.abs(projection.transform.f - 306.82) < 0.001); - - const rotated = projectTerrainToCompass({ - box: { left: 10, top: 20, width: 245, height: 260 }, - terrain: projectionTerrain, - playerMapX: 0, - playerMapY: 0, - directionX: 1, - directionY: 0, - }); - assert.ok(rotated); - assert.ok(Math.abs(rotated.transform.a) === 0); - assert.ok(rotated.transform.b < 0); - assert.ok(rotated.transform.c > 0); -}); - const projectionTerrain = { canvas: {} as HTMLCanvasElement, mapLeft: 0, diff --git a/tests/unit/native-compass-terrain.test.ts b/tests/unit/native-compass-terrain.test.ts new file mode 100644 index 00000000..7150f1df --- /dev/null +++ b/tests/unit/native-compass-terrain.test.ts @@ -0,0 +1,97 @@ +/** Verifies tile stability, bounded uploads and renderer cleanup without a live client. */ +import assert from "node:assert/strict"; +import { CARTOGRAPHY_BUILTIN_PRESETS } from "../../src/shared/cartography-overlay.js"; +import test from "node:test"; +import { createNativeCompassTerrainLayer, nativeCompassTerrainTile, type NativeCompassTerrainInput } from "../../src/renderer/cartography-spike/native-compass-terrain-layer.js"; +import { NATIVE_COMPASS_TERRAIN_HEADER_BYTES, NATIVE_COMPASS_TERRAIN_MAGIC } from "../../src/shared/native-compass-terrain.js"; + +test("nearby camera motion reuses a bounded terrain tile with a full Compass margin", () => { + const input = {cameraX: 0, cameraY: 0, width: 245, height: 245, scaleX: 1, scaleY: 1, dpr: 2}; + const initial = nativeCompassTerrainTile(input); assert.ok(initial); + assert.deepEqual(nativeCompassTerrainTile({...input, cameraX: 2000, cameraY: -2000}), initial); + const moved = nativeCompassTerrainTile({...input, cameraX: 2100}); assert.ok(moved); + assert.equal(moved.left - initial.left, 4096); + assert.ok(2100 - moved.left > 4500 && moved.left + 16384 - 2100 > 4500); + assert.equal(initial.size & (initial.size - 1), 0); + assert.equal(nativeCompassTerrainTile({...input, cameraX: NaN}), null); + assert.equal(nativeCompassTerrainTile({...input, width: 0}), null); + assert.equal(nativeCompassTerrainTile({...input, scaleX: 1000}), null); +}); + +class CanvasPeer { + width = 0; height = 0; + context = { + setTransform() {}, clearRect() {}, save() {}, restore() {}, scale() {}, + beginPath() {}, rect() {}, arc() {}, clip() {}, drawImage() {}, fillRect() {}, + moveTo() {}, lineTo() {}, closePath() {}, stroke() {}, setLineDash() {}, + getImageData: (_x: number, _y: number, width: number, height: number) => { + const data = new Uint8ClampedArray(width * height * 4); data.set([17, 34, 51, 128]); return {data}; + }, + }; + getContext() { return this.context; } +} + +for (const address of [2048, 0x8000_0800]) { +test(`native terrain uploads and frees copied input at wasm32 address ${address.toString(16)}`, () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, "window"); + const peer = new EventTarget(); Object.defineProperty(globalThis, "window", {configurable: true, value: peer}); + try { + const canvases: CanvasPeer[] = []; + const document = {defaultView: {devicePixelRatio: 1}, createElement() { const canvas = new CanvasPeer(); canvases.push(canvas); return canvas; }}; + const memory = new WebAssembly.Memory({initial: Math.ceil((address + 4 * 1024 * 1024) / 65536)}); + const globals = Object.fromEntries(["area", "serial", "updates", "uploads", "created", "destroyed", "size", "camera_x", "camera_y", "width", "height"].map((name) => [ + `gwonmac_compass_terrain_${name}`, new WebAssembly.Global({value: ["camera_x", "camera_y", "width", "height"].includes(name) ? "f32" : "i32", mutable: true}, ["width", "height"].includes(name) ? 245 : 0), + ])); + const global = (name: string) => { const value = globals[`gwonmac_compass_terrain_${name}`]; assert.ok(value); return value; }; + let allocations = 0; let releases = 0; let uploads = 0; let fail = false; + const exports = {...globals, memory, + malloc: (bytes: number) => { assert.ok(bytes <= 4 * 1024 * 1024 - 2048); allocations += 1; return address | 0; }, + free: (region: number) => { assert.equal(region, address); releases += 1; }, + gwonmac_compass_terrain_hide: () => { global("area").value = 0; }, + gwonmac_compass_terrain_publish: (region: number, bytes: number) => { + assert.equal(region, address, "signed allocator bits become an unsigned byte offset"); + if (fail) throw new Error("native refusal"); + const header = new DataView(memory.buffer, region, NATIVE_COMPASS_TERRAIN_HEADER_BYTES); + assert.equal(header.getUint32(0, true), NATIVE_COMPASS_TERRAIN_MAGIC); + assert.equal(header.getUint32(4, true), bytes); + assert.deepEqual([...new Uint8Array(memory.buffer, region + 32, 4)], [51, 34, 17, 128]); + global("area").value = header.getUint32(8, true); global("serial").value = header.getUint32(28, true); + global("uploads").value = ++uploads; return 1; + }, + }; + // Test-only DOM boundary: the painter peer implements its canvas operations. + const layer = createNativeCompassTerrainLayer(exports, document as unknown as Document); + assert.equal(layer.available(), true); + assert.equal(createNativeCompassTerrainLayer({}, document as unknown as Document).available(), false); + const input = {area: 7, version: "7:1", anchorX: 100, anchorY: 200, scaleX: 1, scaleY: 1, opacity: 40, + style: {veilColor: "#081014", boundaryColor: "#F2A900", boundaryWidth: 1, boundaryCasingColor: "#FFFFFF"}, + terrain: {canvas: new CanvasPeer() as unknown as HTMLCanvasElement, mapLeft: 0, mapTop: 0, mapUnitsPerPixel: 2}, + } satisfies NativeCompassTerrainInput; + layer.update(input); assert.equal(uploads, 1); + global("camera_x").value = 1000; layer.update(input); assert.equal(uploads, 1); + global("camera_x").value = 3000; layer.update(input); assert.equal(uploads, 2); + const inspection = {...input, inspectionRadius: 1 as const, cartography: { + style: CARTOGRAPHY_BUILTIN_PRESETS.cartographer.style.grid, opacity: 45, + explorationVersion: "1", revealabilityVersion: "1", isExplored: () => true, + isRemaining: () => false, canCurrentMapReveal: () => false, canVisitedMapReveal: () => false, + }}; + layer.update(inspection); assert.equal(uploads, 3); + global("camera_x").value = 3100; layer.update(inspection); + assert.equal(uploads, 3, "inspection follows native camera without subcell repaint"); + global("camera_x").value = 6000; layer.update(inspection); + assert.equal(uploads, 4, "a newly selected inspection cell repaints within the same cached tile"); + assert.equal(allocations, releases); + peer.dispatchEvent(new Event("gw:graphics-context-reset")); + assert.equal(window.gwNativeCompassTerrainStats?.().status, "hidden"); + fail = true; assert.throws(() => layer.update(input), /native refusal/); assert.equal(allocations, releases); + layer.dispose(); layer.dispose(); assert.equal(window.gwNativeCompassTerrainStats, undefined); + assert.equal(layer.available(), false); + assert.ok(canvases.every((canvas) => canvas.width === 0 && canvas.height === 0)); + const before = allocations; layer.update(input); assert.equal(allocations, before); + global("area").value = 9; peer.dispatchEvent(new Event("gw:graphics-context-reset")); assert.equal(global("area").value, 9); + } finally { + if (previous) Object.defineProperty(globalThis, "window", previous); else Reflect.deleteProperty(globalThis, "window"); + } +}); + +} diff --git a/tests/unit/native-map-graphics.test.ts b/tests/unit/native-map-graphics.test.ts new file mode 100644 index 00000000..e9fcc5ec --- /dev/null +++ b/tests/unit/native-map-graphics.test.ts @@ -0,0 +1,204 @@ +/** Verifies remaining-only painting, native map coordinates and copied input cleanup. */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createCartographyGridLayer } from "../../src/renderer/cartography-spike/cartography-grid-layer.js"; +import { createNativeMapGraphicsLayer, nativeMapGraphicsCorners } from "../../src/renderer/cartography-spike/native-map-graphics-layer.js"; +import { CARTOGRAPHY_BUILTIN_PRESETS } from "../../src/shared/cartography-overlay.js"; +import type { CartographyGridProjection } from "../../src/renderer/cartography-spike/cartography-grid-projection.js"; + +class CanvasPeer { + width = 128; height = 128; + strokes = 0; fills = 0; texts: string[] = []; + context = { + setTransform() {}, clearRect: () => { this.strokes = 0; this.fills = 0; this.texts = []; }, save() {}, restore() {}, scale() {}, + beginPath() {}, rect() {}, arc() {}, clip() {}, drawImage() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, setLineDash() {}, + stroke: () => { this.strokes += 1; }, fill: () => { this.fills += 1; }, fillText: (value: string) => { this.texts.push(value); }, strokeText() {}, + getImageData: (_x: number, _y: number, width: number, height: number) => ({data: new Uint8ClampedArray(width * height * 4)}), + }; + getContext() { return this.context; } +} +const projection: CartographyGridProjection = { + surface: "mission-map", box: {left: 100, top: 200, width: 128, height: 128}, + clip: {kind: "rectangle"}, transform: {a: 1, b: 0, c: 0, d: 1, e: 0, f: 0}, + firstCellX: 0, lastCellX: 3, firstCellY: 0, lastCellY: 3, currentCell: {x: 1, y: 1}, +}; +function documentPeer() { + const view = Object.assign(new EventTarget(), {devicePixelRatio: 1}); + const canvases: CanvasPeer[] = []; + const document = {defaultView: view, createElement(tag: string) { + assert.equal(tag, "canvas", "map graphics do not create positioned browser surfaces"); + const canvas = new CanvasPeer(); canvases.push(canvas); return canvas; + }}; + return {document: document as unknown as Document, view, canvases}; +} + +test("explored areas paint nothing; all maps retain the chosen remaining icon and local hover", () => { + const {document, canvases} = documentPeer(); + const layer = createCartographyGridLayer(document); + const input = {projection, style: CARTOGRAPHY_BUILTIN_PRESETS.cartographer.style.grid, opacity: 70, + explorationVersion: "1", revealabilityVersion: "1", isExplored: () => true, isRemaining: () => false, + canCurrentMapReveal: () => true, canVisitedMapReveal: () => false, hoveredCell: null, revealRadius: 1 as const}; + layer.update(input); + assert.equal(canvases[0]?.strokes, 0, "no permanent lattice, current-cell border or reveal range"); + assert.equal(canvases[0]?.fills, 0, "no explored-area wash"); + layer.update({...input, explorationVersion: "2", isExplored: (x, y) => x !== 2 || y !== 2, + isRemaining: (x, y) => x === 2 && y === 2}); + assert.equal(canvases[0]?.strokes, 2, "one cased marker for one remaining cell"); + layer.update({...input, projection: {...projection, surface: "compass"}, explorationVersion: "3", + isExplored: () => false, isRemaining: () => true, canCurrentMapReveal: () => false}); + assert.equal(canvases[0]?.strokes, 0, "Compass excludes unconfirmed estimates"); + layer.update({...input, projection: {...projection, surface: "compass"}, explorationVersion: "4", + isExplored: (x, y) => x !== 2 || y !== 2, isRemaining: (x, y) => x === 2 && y === 2}); + assert.equal(canvases[0]?.strokes, 2, "Compass shows confirmed remaining cells"); + layer.update({...input, hoveredCell: {x: 2, y: 2}, revealRadius: 0}); + assert.equal(canvases[0]?.strokes, 2, "hover outlines only the selected cell"); + layer.dispose(); assert.equal(layer.image(), null); assert.equal(canvases[0]?.width, 0); +}); + +test("native corners preserve world coordinates through pan, zoom and window moves", () => { + assert.deepEqual(nativeMapGraphicsCorners(projection), [0, 0, 128, 0, 128, 128, 0, 128]); + const zoomed = {...projection, transform: {a: 2, b: 0, c: 0, d: 2, e: -200, f: -400}}; + assert.deepEqual(nativeMapGraphicsCorners(zoomed), [100, 200, 164, 200, 164, 264, 100, 264]); + assert.deepEqual(nativeMapGraphicsCorners({...zoomed, box: {...zoomed.box, left: 400}}), nativeMapGraphicsCorners(zoomed)); + assert.equal(nativeMapGraphicsCorners({...projection, transform: {...projection.transform, a: 0}}), null); + assert.equal(nativeMapGraphicsCorners({...projection, transform: {...projection.transform, e: NaN}}), null); +}); + +test("native maps cache textures and release copied input on success, refusal and exceptions", () => { + const {document, view, canvases} = documentPeer(); + const memory = new WebAssembly.Memory({initial: 8}); + const serial = new WebAssembly.Global({value: "i32", mutable: true}); + let allocations = 0; let releases = 0; let uploads = 0; let behavior: "accept" | "refuse" | "throw" = "accept"; + const exports = {memory, gwonmac_mission_graphics_serial: serial, + malloc: () => { allocations += 1; return 2048; }, free: () => { releases += 1; }, + gwonmac_mission_graphics_hide: () => { serial.value = 0; }, + gwonmac_mission_graphics_publish: (region: number, bytes: number) => { + uploads += 1; + const header = new DataView(memory.buffer, region, 64); + assert.equal(bytes, 64 + 128 * 128 * 4); assert.equal(header.getUint32(8, true), 7); + assert.equal(header.getFloat32(48, true), 128); + if (behavior === "throw") throw new Error("native failure"); + if (behavior === "refuse") return 0; + serial.value = header.getUint32(20, true); return 1; + }, + }; + const layer = createNativeMapGraphicsLayer(exports, document); + assert.equal(layer.available("mission"), true); + assert.equal(layer.available("world"), false, "native availability is independent for each map"); + assert.equal(layer.available("mission_hover"), false); + const image = new CanvasPeer(); + const input = {area: 7, continent: 2, projection, images: [{canvas: image as unknown as HTMLCanvasElement, version: "1"}]}; + layer.update("mission", input); layer.update("mission", input); assert.equal(uploads, 1); + layer.update("mission", {...input, projection: {...projection, box: {...projection.box, left: 900}}}); + assert.equal(uploads, 1, "moving the native window does not repaint map artwork"); + view.dispatchEvent(new Event("gw:graphics-context-reset")); layer.update("mission", input); assert.equal(uploads, 2); + behavior = "refuse"; layer.hide("mission"); layer.update("mission", input); assert.equal(serial.value, 0); + behavior = "throw"; assert.throws(() => layer.update("mission", input), /native failure/); + assert.equal(allocations, releases); + layer.dispose(); layer.dispose(); assert.ok(canvases.every((canvas) => canvas.width === 0)); + const before = allocations; layer.update("mission", input); assert.equal(allocations, before); + assert.equal(layer.available("mission"), false); +}); + +test("map tiles absorb small pans and zoom animation; hover uses only a small cell texture", async () => { + const {nativeMapTileProjection, nativeMapHoverProjection} = await import("../../src/renderer/cartography-spike/native-map-tile.js"); + const view = {...projection, box: {...projection.box, width: 900, height: 600}, transform: {...projection.transform, e: -2000, f: -3000}}; + const first = nativeMapTileProjection(view); + const panned = nativeMapTileProjection({...view, transform: {...view.transform, e: -2010, f: -3010}}); + assert.deepEqual(panned, first, "small pans do not invalidate terrain or markers"); + const zoomFrame = nativeMapTileProjection({...view, transform: {...view.transform, a: 1.001, d: 1.001}}); + assert.deepEqual(zoomFrame, first, "sub-detail zoom frames reuse the texture while native projection moves it"); + const crossed = nativeMapTileProjection({...view, transform: {...view.transform, e: -2300}}); + assert.notDeepEqual(crossed.transform, first.transform); + const hover = nativeMapHoverProjection(view, {x: 65, y: 95}, 0); + assert.equal(hover.box.width, 44); assert.equal(hover.box.height, 44); + assert.deepEqual(nativeMapGraphicsCorners(hover), [2074, 3034, 2118, 3034, 2118, 3078, 2074, 3078]); +}); + + +test("large cached map tiles retain remaining guidance above the detailed-marker budget", async () => { + const {nativeMapTileProjection} = await import("../../src/renderer/cartography-spike/native-map-tile.js"); + for (const surface of ["mission-map", "world-map"] as const) { + const {document, canvases} = documentPeer(); + const layer = createCartographyGridLayer(document); + const tile = nativeMapTileProjection({...projection, surface, + box: {...projection.box, width: 1280, height: 720}, + transform: {...projection.transform, a: 0.6, d: 0.6}}); + const input = {projection: tile, style: CARTOGRAPHY_BUILTIN_PRESETS.cartographer.style.grid, + opacity: 70, explorationVersion: "1", revealabilityVersion: "1", + isExplored: () => false, isRemaining: () => true, canCurrentMapReveal: () => true, + canVisitedMapReveal: () => false, hoveredCell: null, revealRadius: 0 as const}; + layer.update(input); + assert.ok(canvases[0]!.texts.length > 0, "overscan must not blank remaining guidance"); + assert.ok(canvases[0]!.texts.length <= 4096, "cluster artwork remains bounded"); + assert.ok(canvases[0]!.texts.every(value => value === "16"), "clusters retain exact current-map counts"); + const large = nativeMapTileProjection({...projection, surface, + box: {...projection.box, width: 8000, height: 4000}, + transform: {...projection.transform, a: 0.6, d: 0.6}}); + layer.update({...input, projection: large}); + assert.ok(canvases[0]!.texts.length > 0 && canvases[0]!.texts.length <= 4096, + "very large tiles select larger clusters within the artwork budget"); + layer.update({...input, explorationVersion: "2", isExplored: () => true, isRemaining: () => false}); + assert.equal(canvases[0]!.texts.length, 0, "explored tiles remain clear"); + layer.dispose(); + } +}); + + +test("map disposal drains every surface and canvas when one native withdrawal throws", () => { + const {document, view, canvases} = documentPeer(); + const withdrawn: string[] = []; + const exports = Object.fromEntries(["mission", "world"].flatMap(name => [ + [`gwonmac_${name}_graphics_publish`, (): number => 1], + [`gwonmac_${name}_graphics_hide`, (): void => { withdrawn.push(name); if (name === "world") throw new Error("native failure"); }], + [`gwonmac_${name}_graphics_serial`, new WebAssembly.Global({value: "i32"})], + ])); + const layer = createNativeMapGraphicsLayer(exports, document); + assert.equal(layer.available("mission"), false, "copied-input allocator is part of availability"); + assert.throws(() => layer.dispose(), /Maps cleanup failed/); + assert.deepEqual(withdrawn.sort(), ["mission", "world"]); + assert.ok(canvases.every(canvas => canvas.width === 0 && canvas.height === 0)); + layer.dispose(); + view.dispatchEvent(new Event("gw:graphics-context-reset")); + assert.equal(withdrawn.length, 2, "dispose remains final even after a native release error"); +}); + + +test("native maps and Compass ranges upload and free copied pixels above 2 GiB", async () => { + const {createNativeCompassRangesLayer} = await import("../../src/renderer/cartography-spike/native-compass-ranges-layer.js"); + const {document} = documentPeer(); + const address = 0x8000_1000; + // WebAssembly reserves virtual pages; this fixture touches only the small upload region. + const memory = new WebAssembly.Memory({initial: 32832}); + const freed: number[] = []; + const published: string[] = []; + const serial = new WebAssembly.Global({value: "i32", mutable: true}); + const area = new WebAssembly.Global({value: "i32", mutable: true}); + const inspect = (kind: string, region: number, bytes: number, serialOffset: number) => { + assert.equal(region, address); + const header = new DataView(memory.buffer, region, bytes); + assert.equal(header.getUint32(4, true), bytes); + assert.equal(header.getUint32(8, true), 7); + serial.value = header.getUint32(serialOffset, true); + area.value = 7; + published.push(kind); + return 1; + }; + const common = {memory, malloc: () => address | 0, free: (region: number) => { freed.push(region); }}; + const map = createNativeMapGraphicsLayer({...common, + gwonmac_mission_graphics_serial: serial, + gwonmac_mission_graphics_hide: () => { serial.value = 0; }, + gwonmac_mission_graphics_publish: (region: number, bytes: number) => inspect("mission", region, bytes, 20), + }, document); + const range = createNativeCompassRangesLayer({...common, + gwonmac_compass_ranges_serial: serial, gwonmac_compass_ranges_area: area, + gwonmac_compass_ranges_hide: () => { serial.value = 0; }, + gwonmac_compass_ranges_publish: (region: number, bytes: number) => inspect("ranges", region, bytes, 28), + }, document); + const image = {canvas: new CanvasPeer() as unknown as HTMLCanvasElement, version: "1"}; + map.update("mission", {area: 7, continent: 2, projection, images: [image]}); + range.update(image, 7); + assert.deepEqual(published, ["mission", "ranges"]); + assert.deepEqual(freed, [address, address]); + map.dispose(); range.dispose(); +});