forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c9809d3e0 | ||
|
|
ed8b9e3719 | ||
|
|
147c2804d4 | ||
|
|
e7cc588bfc | ||
|
|
bac0da9300 |
No files matched your search
@@ -4,6 +4,39 @@ This file is the durable record of runtime behavior, regressions, and the
|
|||||||
evidence required before calling a visual bug fixed. Update it whenever scene,
|
evidence required before calling a visual bug fixed. Update it whenever scene,
|
||||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
camera, input, physics, mob-rendering, or asset-loading code changes.
|
||||||
|
|
||||||
|
## 2026-09-10 Personal UI templates and desktop action bars
|
||||||
|
|
||||||
|
- Opened Wailing Caverns through the normal offline roster, dungeon, and role
|
||||||
|
selection screens, then opened Pause > Edit UI & templates.
|
||||||
|
- Verified five bars containing 12 buttons each, including 2 x 6 and 3 x 4
|
||||||
|
arrangements. Default stacking accounts for each bar's height. The editor
|
||||||
|
can collapse or dock on either side so drag handles remain reachable.
|
||||||
|
- Clicked Renew and pressed Shift+Q in keybind edit mode. Reassigned Shift+Q
|
||||||
|
to bar 5/button 12 and verified the previous keybind cleared. Assigned Renew
|
||||||
|
to that button through the ability picker; after saving, Shift+Q cast it and
|
||||||
|
displayed its cooldown. No spell fired during key capture.
|
||||||
|
- Dragged the minimap, hid party frames, saved/renamed/overwrote PC slot 1,
|
||||||
|
loaded it, and round-tripped all layouts through export text and import.
|
||||||
|
The imported JSON retained the minimap position, hidden party, bar geometry,
|
||||||
|
ability assignment, key chord, and template name. Restoring a hidden widget
|
||||||
|
works, and Cancel discards that visibility edit.
|
||||||
|
- At a 390 x 844 viewport, the editor remained within the viewport and the
|
||||||
|
Save UI button was visible and 36 CSS pixels high. The in-app browser's
|
||||||
|
screenshot preview scales the page unusually at this override; DOM bounds
|
||||||
|
confirmed the editor was 310 CSS pixels wide. This was responsive browser
|
||||||
|
verification, not an Android hardware playtest.
|
||||||
|
- Evidence: `playtest-artifacts/ui-layout-desktop.png` and
|
||||||
|
`playtest-artifacts/ui-layout-phone-viewport.png`. Restored the offline UI
|
||||||
|
to defaults and removed the temporary template via import after testing.
|
||||||
|
- Automated coverage includes migration to six slots, owner isolation,
|
||||||
|
cloud upload/hydration into a separate device cache with a mocked endpoint,
|
||||||
|
malformed imports, key conflicts, and input precedence. No production
|
||||||
|
account credentials were used for the cloud round-trip test.
|
||||||
|
- `npm run build` and the focused 67-test suite passed. The development server
|
||||||
|
was restarted after an existing watcher hit EBUSY on generated web-bootstrap
|
||||||
|
assets during a concurrent build; no runtime UI error was logged in the
|
||||||
|
subsequent playtest.
|
||||||
|
|
||||||
## 2026-08-18 GM Manastorm administration - COMPLETE
|
## 2026-08-18 GM Manastorm administration - COMPLETE
|
||||||
|
|
||||||
- Server-authorized `Phenom` access, revisioned SQLite configuration, safe
|
- Server-authorized `Phenom` access, revisioned SQLite configuration, safe
|
||||||
@@ -1839,3 +1872,179 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
no environment-performance regressions. The release-candidate check accepted
|
no environment-performance regressions. The release-candidate check accepted
|
||||||
all 118 changed paths. The existing large-chunk advisory and Rapier
|
all 118 changed paths. The existing large-chunk advisory and Rapier
|
||||||
initialization deprecation warning remain informational.
|
initialization deprecation warning remain informational.
|
||||||
|
|
||||||
|
# 2026-09-09 — fixed per-rank classic mana costs
|
||||||
|
|
||||||
|
- Imported WoW mana percentages are now converted into fixed costs at each
|
||||||
|
spell rank's source level. Player level, Intellect, equipment, and maximum-mana
|
||||||
|
modifiers no longer increase the cost of an already learned rank; legitimate
|
||||||
|
talent and aura cost reductions are still applied afterward.
|
||||||
|
- Shadow Word: Pain rank 4 now costs 133 mana at every level from 26 through 33,
|
||||||
|
including level 32. Learning rank 5 at level 34 raises the cost to 188 mana.
|
||||||
|
- AI companions now use real character-scale mana pools so fixed spell costs do
|
||||||
|
not prevent healing or resurrection. The companion display also resolves the
|
||||||
|
active spell rank and the same final affordability result as the primary HUD.
|
||||||
|
- Verification passed all 1,133 Vitest cases across 208 files. The production
|
||||||
|
build completed with 974 modules transformed; arena, KTX2, and environment-
|
||||||
|
performance audits remained green, and the release-candidate check passed.
|
||||||
|
|
||||||
|
# 2026-09-09 — dungeon wall and nameplate performance regressions
|
||||||
|
|
||||||
|
- The 0.1.24 material fallback honored BLEND tags on legacy WoW WMO walls,
|
||||||
|
floors, and ceilings, disabling their depth writes. WoW environments now use
|
||||||
|
their authored material roles again; RuneWaker environments retain their
|
||||||
|
source MASK/BLEND semantics. Shipped Wailing Caverns wall materials, vines,
|
||||||
|
and water are covered by regression tests.
|
||||||
|
- The 0.1.25 boss-name occlusion scanned the complete rendered scene with
|
||||||
|
Three.js raycasts. A live Kalin's Shrine CPU profile was dominated by triangle
|
||||||
|
intersection and vertex decoding. Boss names and nearby NPC prompts now use
|
||||||
|
bounded camera-to-label queries against Rapier's existing physical spatial
|
||||||
|
index, at most ten times per second, skipping offscreen labels. Walls, floors,
|
||||||
|
and ceilings occlude labels; actors and trigger sensors do not.
|
||||||
|
- In an isolated offline Edge playtest at 1440 x 900 on an AMD RX 9070 XT,
|
||||||
|
Kalin's Shrine improved from about 10 FPS (98.5 ms/frame, 167 draw calls,
|
||||||
|
DPR 1.125) to 55.5-56.1 FPS (17.8-18.0 ms/frame, 168-170 draw calls,
|
||||||
|
DPR 1.5). This is a representative entrance/combat comparison, not an audit
|
||||||
|
of all thirty RoM dungeons. The environment alone took about 0.8 ms CPU and
|
||||||
|
0.5 ms GPU with 14 draw calls.
|
||||||
|
- Wailing Caverns screenshots show continuous opaque cavern walls and ceilings.
|
||||||
|
A controlled toggle of the old material flags reproduced the missing wall
|
||||||
|
sections at the same camera position; restoring depth-writing materials fixed
|
||||||
|
them. Its full scene ran at about 57 FPS. No uncaught client errors appeared.
|
||||||
|
- Evidence and repeatable local benchmark helpers are under
|
||||||
|
`playtest-artifacts/2026-09-09-dungeon-rendering/`: `kalin-before.cpuprofile`,
|
||||||
|
`kalin-after.cpuprofile`, `kalin-after-metrics.json`, `kalin-before.png`,
|
||||||
|
`kalin-after.png`, `wailing-regression.png`, and `wailing-restored.png`.
|
||||||
|
- All 26 focused material, label occlusion, combat sight, aggro visibility,
|
||||||
|
instancing, and NPC-prompt tests passed. Physical-occlusion tests exercise
|
||||||
|
actual Rapier worlds rather than just source-code assertions.
|
||||||
|
|
||||||
|
# 2026-09-09 — campaign rendering audit and Thor performance preparation
|
||||||
|
|
||||||
|
- Audited the actual visual GLB headers for all 54 WoW campaign dungeons:
|
||||||
|
9,402 materials, including 3,586 legacy BLEND materials restored to opaque
|
||||||
|
rendering by the shared policy. Every WoW package contained affected
|
||||||
|
materials. A permanent shipped-asset test covers all 54 definitions and
|
||||||
|
retains explicit cutout, blend, and additive roles. This is material-policy
|
||||||
|
coverage, not a claim that every room has been visually traversed.
|
||||||
|
- Live screenshots checked Wailing Caverns, Shadowfang Keep, Utgarde Pinnacle,
|
||||||
|
the Nexus, and Tinkertech Showdown. The sampled walls, floors, stairs, and
|
||||||
|
ceilings remain solid; authored lighting effects remain transparent. Kalin's
|
||||||
|
Shrine and Forsaken Abbey provided RoM rendering checks. Roster and in-dungeon
|
||||||
|
screenshots show character bodies and equipped weapon silhouettes after the
|
||||||
|
shared character animation change.
|
||||||
|
- A controlled Utgarde Pinnacle entrance test found about 1,739 draw calls,
|
||||||
|
1.95 million triangles, and 30.5 FPS on the desktop GPU. Clipping at the
|
||||||
|
existing full-fog distance reduced this to about 209 calls, 567,000 triangles,
|
||||||
|
and 60 FPS at the same viewpoint. The camera now follows each dungeon's fog
|
||||||
|
distance, including transitions that retain the existing Canvas/camera.
|
||||||
|
The background uses the fog color to avoid a clipping seam at the horizon.
|
||||||
|
All 84 dungeon definitions have render-distance coverage.
|
||||||
|
- Static dungeon visual transforms are cached until the root or its parent's
|
||||||
|
world transform changes. Tests verify imported matrices, repeated unchanged
|
||||||
|
frames, root movement, and parent movement. Characters, moving world objects,
|
||||||
|
and physics remain outside this immutable visual cache.
|
||||||
|
The final 63-second Utgarde sample held 60 FPS with 213 draw calls:
|
||||||
|
main-thread work averaged 9.2 ms/frame, renderer CPU averaged 7.5 ms
|
||||||
|
(p95 8.3 ms), and GPU averaged 2.7 ms (p95 4.1 ms). Movement and jump input
|
||||||
|
continued to animate the player while the environment remained stationary.
|
||||||
|
- Character locomotion and jump clip selections are cached by animation
|
||||||
|
library and weapon stance. The final Kalin CPU profile contains no sampled
|
||||||
|
per-frame base-clip selection/name parsing. Frame-cap deadlines now tolerate
|
||||||
|
sub-millisecond timestamp rounding without drifting or emitting catch-up
|
||||||
|
frames; tests cover 60 FPS on rounded 60 Hz and 120 Hz callbacks.
|
||||||
|
- A 31.5-second Kalin sample with a connected browser companion display held
|
||||||
|
60 FPS: main-thread work averaged 8.7 ms/frame, renderer CPU averaged 5.4 ms
|
||||||
|
(p95 5.9 ms), and GPU averaged 2.4 ms (p95 3.4 ms). The companion used about
|
||||||
|
5.1 ms of main-thread time per second. Recent ability-cost snapshot work was
|
||||||
|
not a dominant hotspot in this sample. Startup warmup is bounded to loading;
|
||||||
|
companion snapshots are already limited to ten per second during gameplay.
|
||||||
|
- These are isolated offline Edge desktop samples on an AMD RX 9070 XT at
|
||||||
|
1920 x 1080 CSS pixels, DPR 1.5 (2880 x 1620 renderbuffer). Party composition
|
||||||
|
can change between dungeon entries. They do not certify Android performance
|
||||||
|
or sustained busy-combat headroom. ADB listed no connected Android device.
|
||||||
|
- Thor acceptance should use the actual model, both native displays, target
|
||||||
|
resolution, and a sustained 20-minute traversal/combat run after warmup.
|
||||||
|
Provisional headroom target: p95 main-thread and GPU work each at or below
|
||||||
|
12 ms, with p99 presented frame intervals within the 16.7 ms 60-FPS budget.
|
||||||
|
Test large pulls, boss effects, menus, and repeated dungeon transitions;
|
||||||
|
record thermal behavior and frame-time spikes, not only average FPS.
|
||||||
|
The environment asset audit still has 60 grandfathered budget exceptions;
|
||||||
|
its green result means no new asset regression, not a Thor FPS guarantee.
|
||||||
|
- Evidence is in `playtest-artifacts/2026-09-09-thor-audit/`, including roster,
|
||||||
|
dungeon, and companion screenshots, CPU profiles, and CPU/GPU JSON samples.
|
||||||
|
The full material report is
|
||||||
|
`playtest-artifacts/2026-09-09-dungeon-rendering/wow-material-audit.json`.
|
||||||
|
- Validation passed 227 focused tests covering avatar rendering and animation,
|
||||||
|
camera distances, material policies, frame pacing, static transforms, label
|
||||||
|
visibility, and companion snapshots. The final static-cache change also
|
||||||
|
passed the 105-test scene subset. The production build and release-candidate
|
||||||
|
check passed; the existing large-bundle advisory remains.
|
||||||
|
|
||||||
|
# 2026-09-09 — additional CPU headroom from culling and shared skeletons
|
||||||
|
|
||||||
|
- Prepared dungeon visuals now group static siblings into conservative spatial
|
||||||
|
bounds. One scene hook checks these bounds after the current camera and world
|
||||||
|
matrices update, before Three.js traverses individual meshes. Bounds include
|
||||||
|
complete wall geometry and all instances; lights, animated meshes, and meshes
|
||||||
|
explicitly disabling frustum culling remain outside these groups. The hook
|
||||||
|
supports streamed chunks and restores its predecessor after cleanup.
|
||||||
|
- At the same Utgarde Pinnacle entrance, only 16 of 1,383 spatial groups were
|
||||||
|
visible. The renderer examined 108 static meshes instead of 7,002, retaining
|
||||||
|
the same 206 draw calls and 566,913 rendered triangles. Controlled culling
|
||||||
|
toggles produced byte-identical canvas PNGs across eight camera headings.
|
||||||
|
- In the same running scene, spatial grouping reduced average main-thread work
|
||||||
|
from 9.45 to 6.83 ms/frame, and renderer CPU from 7.81 to 5.31 ms/frame.
|
||||||
|
Removing containers emptied by static instancing reduced the final fresh-run
|
||||||
|
averages further to 6.49 ms main-thread and 4.85 ms renderer CPU (p95 5.2 ms).
|
||||||
|
The final sample lasted 87.8 seconds and held 60 FPS. Authored empty scene
|
||||||
|
nodes are preserved; only containers emptied by our batching are removed.
|
||||||
|
- The shared character renderer still uses SkeletonUtils.clone, then reuses
|
||||||
|
equivalent skeleton palettes within that owned clone. Bone identities, order,
|
||||||
|
and inverse bind matrices must all match; per-mesh bind transforms remain
|
||||||
|
unchanged. Different characters retain independent bones and animations.
|
||||||
|
Owned bone textures are now explicitly disposed during character teardown.
|
||||||
|
- A live character audit found 66 skeleton objects for one player palette and
|
||||||
|
256 objects for four party palettes. A Wailing Caverns toggle removed 309
|
||||||
|
duplicate skeletons and produced an identical rendered image. Tests verify
|
||||||
|
posed vertices, differing bind transforms, incompatible palettes, independent
|
||||||
|
cloned characters, and redundant texture disposal.
|
||||||
|
In the same Wailing scene, main-thread work fell from 7.40 to 6.70 ms/frame;
|
||||||
|
renderer CPU fell from 5.51 to 4.86 ms/frame, and allocated GPU textures fell
|
||||||
|
from 292 to 217. This measures palette sharing after spatial culling was
|
||||||
|
already enabled. Both samples held 60 FPS.
|
||||||
|
- The final built runtime showed a complete roster body and staff with one
|
||||||
|
skeleton for 66 skinned meshes. Kalin's Shrine showed one player palette and
|
||||||
|
four independent party palettes; walking retained the player body and weapon
|
||||||
|
silhouette. Wailing Caverns and Utgarde screenshots retained solid dungeon
|
||||||
|
surfaces. There were no uncaught client errors during these playtests.
|
||||||
|
- All 210 targeted avatar, visibility, instancing, material, camera-distance,
|
||||||
|
and label tests passed. The production build and release-candidate check
|
||||||
|
passed. The existing large-bundle advisory remains.
|
||||||
|
- Evidence is under `playtest-artifacts/2026-09-09-static-culling/`: paired PNGs,
|
||||||
|
eight-heading pixel comparisons, profiles, before/after timing JSON, and
|
||||||
|
screenshots. Measurements use the same desktop Edge/RX 9070 XT setup as the
|
||||||
|
preceding audit. These CPU improvements do not certify Thor performance.
|
||||||
|
|
||||||
|
# 2026-09-09 — bundled arena assets blocked by the browser content worker
|
||||||
|
|
||||||
|
- Reproduced the reported collision-matched fallback in the production build
|
||||||
|
with the content worker active. Warnorken's `visual.glb?arena-runtime=1`
|
||||||
|
returned 404 even though the file was bundled and directly served with 200.
|
||||||
|
The worker omitted `/assets/game/arenas/` from its bundled asset prefixes and
|
||||||
|
incorrectly rejected these files as uninstalled optional content.
|
||||||
|
- Added the arena prefix to the worker. Installed updates still take priority;
|
||||||
|
missing optional dungeon content still returns 404. Behavioral worker tests
|
||||||
|
failed for all eight arenas before the fix and now cover visual, collision,
|
||||||
|
and navigation requests with the runtime query parameter.
|
||||||
|
- The production build passed. Reloading the same browser session activated
|
||||||
|
the fixed worker without clearing storage or downloading content. Warnorken
|
||||||
|
rendered textured stone geometry and Nagrand rendered its authored arena
|
||||||
|
geometry; neither showed the loading/fallback banner after loading, and no
|
||||||
|
new console errors were recorded. Checks used offline practice in the Codex
|
||||||
|
browser at `http://127.0.0.1:4174/`, backed by a local test server on port 4173.
|
||||||
|
- All 22 content tests and the eight-arena asset validator passed. Screenshots,
|
||||||
|
before/after error logs, asset validation, and build output are saved under
|
||||||
|
`playtest-artifacts/2026-09-09-arena-loading/`. The existing large-bundle
|
||||||
|
advisory remains. This verifies the local browser build, not a deployment
|
||||||
|
or an Android package.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.23",
|
"version": "0.1.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.23",
|
"version": "0.1.28",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.23",
|
"version": "0.1.28",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -95,6 +95,7 @@
|
|||||||
"runewaker:audit:encounters": "node scripts/runewaker-pipeline/audit-encounter-implementation.mjs && node --test scripts/runewaker-pipeline/encounter-implementation-report.test.mjs",
|
"runewaker:audit:encounters": "node scripts/runewaker-pipeline/audit-encounter-implementation.mjs && node --test scripts/runewaker-pipeline/encounter-implementation-report.test.mjs",
|
||||||
"runewaker:audit:environment-assembly": "node scripts/runewaker-pipeline/audit-environment-assembly.mjs && node --test scripts/runewaker-pipeline/environment-assembly.test.mjs",
|
"runewaker:audit:environment-assembly": "node scripts/runewaker-pipeline/audit-environment-assembly.mjs && node --test scripts/runewaker-pipeline/environment-assembly.test.mjs",
|
||||||
"runewaker:audit:navigation": "node scripts/runewaker-pipeline/analyze-navigation-gaps.mjs && vitest run src/game/runewakerObjectiveRouteAudit.test.ts",
|
"runewaker:audit:navigation": "node scripts/runewaker-pipeline/analyze-navigation-gaps.mjs && vitest run src/game/runewakerObjectiveRouteAudit.test.ts",
|
||||||
|
"runewaker:audit:material-alpha": "node scripts/runewaker-pipeline/audit-material-alpha.mjs",
|
||||||
"assets:wailing:instance": "gltf-transform instance ../wow335a/dungeon-export-work/WailingCaverns/converted/wailing-caverns.visual.glb src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb --min 3",
|
"assets:wailing:instance": "gltf-transform instance ../wow335a/dungeon-export-work/WailingCaverns/converted/wailing-caverns.visual.glb src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb --min 3",
|
||||||
"assets:wailing:creatures:animated": "node scripts/prepareAnimatedCreatures.mjs",
|
"assets:wailing:creatures:animated": "node scripts/prepareAnimatedCreatures.mjs",
|
||||||
"assets:wailing:creatures:combat": "node scripts/add-combat-creature-clips.mjs",
|
"assets:wailing:creatures:combat": "node scripts/add-combat-creature-clips.mjs",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const BUNDLED_ASSET_PREFIXES = [
|
|||||||
"/assets/characters/wow335a-v1/manifest.json",
|
"/assets/characters/wow335a-v1/manifest.json",
|
||||||
"/assets/characters/wow335a-v1/human/",
|
"/assets/characters/wow335a-v1/human/",
|
||||||
"/assets/creatures/wailing-caverns/",
|
"/assets/creatures/wailing-caverns/",
|
||||||
|
"/assets/game/arenas/",
|
||||||
"/assets/game/dungeons/wailing-caverns/",
|
"/assets/game/dungeons/wailing-caverns/",
|
||||||
];
|
];
|
||||||
const BUNDLED_ASSET_PATHS = new Set([
|
const BUNDLED_ASSET_PATHS = new Set([
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.23",
|
"versionName": "0.1.28",
|
||||||
"versionCode": 1023
|
"versionCode": 1028
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,9 @@ async function main() {
|
|||||||
&& hasBasisu
|
&& hasBasisu
|
||||||
&& /^\/images\/\d+\/mimeType$/.test(issue.pointer ?? "")
|
&& /^\/images\/\d+\/mimeType$/.test(issue.pointer ?? "")
|
||||||
&& issue.message.includes("'image/ktx2'")) return false;
|
&& issue.message.includes("'image/ktx2'")) return false;
|
||||||
|
if (issue.code === "IMAGE_UNRECOGNIZED_FORMAT"
|
||||||
|
&& hasBasisu
|
||||||
|
&& /^\/images\/\d+$/.test(issue.pointer ?? "")) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
rows.push({
|
rows.push({
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
|
||||||
|
const workRoot = path.join(projectRoot, "runewaker-export-work");
|
||||||
|
const shippingRoot = path.join(projectRoot, "src", "assets", "game", "dungeons");
|
||||||
|
const resourceCacheRoot = path.join(workRoot, "resource-cache");
|
||||||
|
const requestedSlugs = new Set(process.argv.slice(2).filter((value) => !value.startsWith("--")));
|
||||||
|
const fix = process.argv.includes("--fix");
|
||||||
|
const alphaClassifications = new Map();
|
||||||
|
let resourceTextureIndex;
|
||||||
|
|
||||||
|
async function exists(file) {
|
||||||
|
try {
|
||||||
|
await stat(file);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGlb(buffer, file) {
|
||||||
|
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") {
|
||||||
|
throw new Error(`${file} is not a GLB file.`);
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
let json;
|
||||||
|
let offset = 12;
|
||||||
|
while (offset + 8 <= buffer.length) {
|
||||||
|
const chunkLength = buffer.readUInt32LE(offset);
|
||||||
|
const chunkType = buffer.readUInt32LE(offset + 4);
|
||||||
|
const chunkStart = offset + 8;
|
||||||
|
const chunkEnd = chunkStart + chunkLength;
|
||||||
|
if (chunkEnd > buffer.length) break;
|
||||||
|
const data = buffer.subarray(chunkStart, chunkEnd);
|
||||||
|
chunks.push({ type: chunkType, data });
|
||||||
|
if (chunkType === 0x4e4f534a) {
|
||||||
|
json = JSON.parse(data.toString("utf8").replace(/\0+$/u, "").trim());
|
||||||
|
}
|
||||||
|
offset = chunkEnd;
|
||||||
|
}
|
||||||
|
if (!json) throw new Error(`${file} does not contain a JSON chunk.`);
|
||||||
|
return { json, chunks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeGlb(source, json, chunks) {
|
||||||
|
const encodedJson = Buffer.from(JSON.stringify(json));
|
||||||
|
const paddedJson = Buffer.alloc(Math.ceil(encodedJson.length / 4) * 4, 0x20);
|
||||||
|
encodedJson.copy(paddedJson);
|
||||||
|
const updatedChunks = chunks.map((chunk) => (
|
||||||
|
chunk.type === 0x4e4f534a ? { ...chunk, data: paddedJson } : chunk
|
||||||
|
));
|
||||||
|
const totalLength = 12 + updatedChunks.reduce((total, chunk) => total + 8 + chunk.data.length, 0);
|
||||||
|
const output = Buffer.alloc(totalLength);
|
||||||
|
source.copy(output, 0, 0, 12);
|
||||||
|
output.writeUInt32LE(totalLength, 8);
|
||||||
|
let offset = 12;
|
||||||
|
for (const chunk of updatedChunks) {
|
||||||
|
output.writeUInt32LE(chunk.data.length, offset);
|
||||||
|
output.writeUInt32LE(chunk.type, offset + 4);
|
||||||
|
chunk.data.copy(output, offset + 8);
|
||||||
|
offset += 8 + chunk.data.length;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classifyAlpha(file) {
|
||||||
|
if (alphaClassifications.has(file)) return alphaClassifications.get(file);
|
||||||
|
const metadata = await sharp(file).metadata();
|
||||||
|
if (!metadata.hasAlpha) {
|
||||||
|
alphaClassifications.set(file, "opaque");
|
||||||
|
return "opaque";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||||
|
let hasTransparentPixel = false;
|
||||||
|
for (let offset = 3; offset < data.length; offset += info.channels) {
|
||||||
|
const alpha = data[offset];
|
||||||
|
if (alpha === 255) continue;
|
||||||
|
hasTransparentPixel = true;
|
||||||
|
if (alpha > 0) {
|
||||||
|
alphaClassifications.set(file, "blend");
|
||||||
|
return "blend";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const role = hasTransparentPixel ? "cutout" : "opaque";
|
||||||
|
alphaClassifications.set(file, role);
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceImageIndex(texture) {
|
||||||
|
return texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function indexResourceTextures() {
|
||||||
|
if (resourceTextureIndex) return resourceTextureIndex;
|
||||||
|
resourceTextureIndex = new Map();
|
||||||
|
if (!(await exists(resourceCacheRoot))) return resourceTextureIndex;
|
||||||
|
const cacheDirectories = (await readdir(resourceCacheRoot, { withFileTypes: true }))
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
for (const cacheDirectory of cacheDirectories) {
|
||||||
|
const textureDirectory = path.join(resourceCacheRoot, cacheDirectory.name, "prepared", "textures");
|
||||||
|
if (!(await exists(textureDirectory))) continue;
|
||||||
|
for (const texture of (await readdir(textureDirectory)).filter((file) => file.endsWith(".png")).sort()) {
|
||||||
|
if (!resourceTextureIndex.has(texture)) {
|
||||||
|
resourceTextureIndex.set(texture, path.join(textureDirectory, texture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resourceTextureIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSourceTexture(localDirectory, imageName) {
|
||||||
|
const fileName = `${imageName}.png`;
|
||||||
|
const localFile = path.join(localDirectory, fileName);
|
||||||
|
if (await exists(localFile)) return localFile;
|
||||||
|
return (await indexResourceTextures()).get(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(buffer) {
|
||||||
|
return createHash("sha256").update(buffer).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer) {
|
||||||
|
visualArtifact.size = visualBuffer.length;
|
||||||
|
visualArtifact.sha256 = sha256(visualBuffer);
|
||||||
|
await writeFile(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
const optimizationFile = path.join(path.dirname(metadataFile), "optimization-report.json");
|
||||||
|
if (!(await exists(optimizationFile))) return;
|
||||||
|
const optimization = JSON.parse(await readFile(optimizationFile, "utf8"));
|
||||||
|
if (optimization.optimized) {
|
||||||
|
optimization.optimized.size = visualBuffer.length;
|
||||||
|
if (Object.hasOwn(optimization.optimized, "checksum")) {
|
||||||
|
optimization.optimized.checksum = sha256(visualBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeFile(optimizationFile, `${JSON.stringify(optimization, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditDungeon(recipe) {
|
||||||
|
const slug = recipe.slug;
|
||||||
|
const metadataFile = path.join(shippingRoot, slug, "import-metadata.json");
|
||||||
|
const preparedTextureDirectory = path.join(workRoot, slug, "prepared", "textures");
|
||||||
|
if (!(await exists(metadataFile))) {
|
||||||
|
return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, metadataFile)}`] };
|
||||||
|
}
|
||||||
|
if (!(await exists(preparedTextureDirectory))) {
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
status: "incomplete",
|
||||||
|
problems: [`Missing ${path.relative(projectRoot, preparedTextureDirectory)}`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = JSON.parse(await readFile(metadataFile, "utf8"));
|
||||||
|
const visualArtifact = (metadata.assets ?? metadata.artifacts ?? [])
|
||||||
|
.find((artifact) => artifact.role === "visual");
|
||||||
|
const visualFile = path.join(shippingRoot, slug, visualArtifact?.fileName ?? `${slug}-visual.glb`);
|
||||||
|
if (!(await exists(visualFile))) {
|
||||||
|
return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, visualFile)}`] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceGlb = await readFile(visualFile);
|
||||||
|
const { json: gltf, chunks } = parseGlb(sourceGlb, visualFile);
|
||||||
|
const materialResults = [];
|
||||||
|
const missingTextures = [];
|
||||||
|
for (const [materialIndex, material] of (gltf.materials ?? []).entries()) {
|
||||||
|
const textureIndex = material.pbrMetallicRoughness?.baseColorTexture?.index;
|
||||||
|
const texture = gltf.textures?.[textureIndex];
|
||||||
|
const image = gltf.images?.[sourceImageIndex(texture)];
|
||||||
|
if (!image?.name) continue;
|
||||||
|
|
||||||
|
const sourceFile = await resolveSourceTexture(preparedTextureDirectory, image.name);
|
||||||
|
if (!sourceFile) {
|
||||||
|
missingTextures.push(`${image.name}.png`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const expectedRole = await classifyAlpha(sourceFile);
|
||||||
|
const expectedAlphaMode = expectedRole === "blend" ? "BLEND" : expectedRole === "cutout" ? "MASK" : "OPAQUE";
|
||||||
|
const actualAlphaMode = material.alphaMode ?? "OPAQUE";
|
||||||
|
const actualRole = material.extras?.semanticRole ?? "missing";
|
||||||
|
materialResults.push({
|
||||||
|
materialIndex,
|
||||||
|
material: material.name ?? `material-${materialIndex}`,
|
||||||
|
texture: image.name,
|
||||||
|
expectedRole,
|
||||||
|
expectedAlphaMode,
|
||||||
|
actualRole,
|
||||||
|
actualAlphaMode,
|
||||||
|
matches: expectedAlphaMode === actualAlphaMode && expectedRole === actualRole,
|
||||||
|
});
|
||||||
|
if (fix && (expectedAlphaMode !== actualAlphaMode || expectedRole !== actualRole)) {
|
||||||
|
if (expectedAlphaMode === "OPAQUE") delete material.alphaMode;
|
||||||
|
else material.alphaMode = expectedAlphaMode;
|
||||||
|
if (expectedAlphaMode === "MASK") material.alphaCutoff = 0.5;
|
||||||
|
else delete material.alphaCutoff;
|
||||||
|
material.extras = { ...(material.extras ?? {}), semanticRole: expectedRole };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mismatches = materialResults.filter((result) => !result.matches);
|
||||||
|
const alphaMaterials = materialResults.filter((result) => result.expectedRole !== "opaque");
|
||||||
|
const problems = [
|
||||||
|
...mismatches.map((result) =>
|
||||||
|
`${result.texture}: expected ${result.expectedAlphaMode}/${result.expectedRole}, got ${result.actualAlphaMode}/${result.actualRole}`,
|
||||||
|
),
|
||||||
|
...[...new Set(missingTextures)].map((file) => `Missing prepared source texture ${file}`),
|
||||||
|
];
|
||||||
|
if (fix && mismatches.length > 0 && missingTextures.length === 0) {
|
||||||
|
const visualBuffer = serializeGlb(sourceGlb, gltf, chunks);
|
||||||
|
await writeFile(visualFile, visualBuffer);
|
||||||
|
if (visualArtifact) {
|
||||||
|
await refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
status: problems.length === 0 ? "green" : fix && missingTextures.length === 0 ? "repaired" : "mismatch",
|
||||||
|
materials: materialResults.length,
|
||||||
|
alphaMaterials: alphaMaterials.map(({ material, texture, expectedAlphaMode }) => ({
|
||||||
|
material,
|
||||||
|
texture,
|
||||||
|
alphaMode: expectedAlphaMode,
|
||||||
|
})),
|
||||||
|
mismatches,
|
||||||
|
problems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeFiles = (await readdir(recipeDirectory))
|
||||||
|
.filter((file) => file.endsWith(".json") && !file.endsWith("-population.json"))
|
||||||
|
.sort();
|
||||||
|
const recipes = [];
|
||||||
|
for (const recipeFile of recipeFiles) {
|
||||||
|
const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeFile), "utf8"));
|
||||||
|
if (!recipe.slug || recipe.slug === "runewaker-player-avatars") continue;
|
||||||
|
if (requestedSlugs.size > 0 && !requestedSlugs.has(recipe.slug)) continue;
|
||||||
|
recipes.push(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const recipe of recipes) {
|
||||||
|
results.push(await auditDungeon(recipe));
|
||||||
|
}
|
||||||
|
|
||||||
|
const failures = results.filter((result) => !["green", "repaired"].includes(result.status));
|
||||||
|
const report = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
dungeonCount: results.length,
|
||||||
|
status: failures.length === 0 ? fix ? "repaired" : "green" : "mismatch",
|
||||||
|
failureCount: failures.length,
|
||||||
|
alphaDungeonCount: results.filter((result) => result.alphaMaterials?.length > 0).length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
if (failures.length > 0) process.exitCode = 1;
|
||||||
@@ -6,6 +6,7 @@ Expected invocation:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from array import array
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -111,6 +112,69 @@ def material_image_key(material: bpy.types.Material) -> str:
|
|||||||
return f"material:{material.name.casefold()}"
|
return f"material:{material.name.casefold()}"
|
||||||
|
|
||||||
|
|
||||||
|
def material_image(material: bpy.types.Material) -> bpy.types.Image | None:
|
||||||
|
if not material.use_nodes or material.node_tree is None:
|
||||||
|
return None
|
||||||
|
for node in material.node_tree.nodes:
|
||||||
|
if node.type == "TEX_IMAGE" and node.image is not None:
|
||||||
|
return node.image
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def image_alpha_role(image: bpy.types.Image | None) -> str:
|
||||||
|
"""Classify source alpha without turning fully opaque RGBA textures translucent."""
|
||||||
|
if image is None or image.channels < 4:
|
||||||
|
return "opaque"
|
||||||
|
pixels = array("f", [0.0]) * len(image.pixels)
|
||||||
|
image.pixels.foreach_get(pixels)
|
||||||
|
alpha_values = pixels[3::image.channels]
|
||||||
|
if min(alpha_values, default=1.0) >= 0.999:
|
||||||
|
return "opaque"
|
||||||
|
if any(0.001 < alpha < 0.999 for alpha in alpha_values):
|
||||||
|
return "blend"
|
||||||
|
return "cutout"
|
||||||
|
|
||||||
|
|
||||||
|
def preserve_material_alpha(material: bpy.types.Material) -> str:
|
||||||
|
image = material_image(material)
|
||||||
|
role = image_alpha_role(image)
|
||||||
|
if role == "opaque" or material.node_tree is None:
|
||||||
|
return role
|
||||||
|
|
||||||
|
image_node = next(
|
||||||
|
node
|
||||||
|
for node in material.node_tree.nodes
|
||||||
|
if node.type == "TEX_IMAGE" and node.image == image
|
||||||
|
)
|
||||||
|
principled = next(
|
||||||
|
(node for node in material.node_tree.nodes if node.type == "BSDF_PRINCIPLED"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if principled is None:
|
||||||
|
return role
|
||||||
|
alpha_output = image_node.outputs.get("Alpha")
|
||||||
|
alpha_input = principled.inputs.get("Alpha")
|
||||||
|
if alpha_output is None or alpha_input is None:
|
||||||
|
return role
|
||||||
|
|
||||||
|
if role == "blend":
|
||||||
|
if not alpha_input.is_linked:
|
||||||
|
material.node_tree.links.new(alpha_output, alpha_input)
|
||||||
|
material.blend_method = "BLEND"
|
||||||
|
if hasattr(material, "surface_render_method"):
|
||||||
|
material.surface_render_method = "BLENDED"
|
||||||
|
else:
|
||||||
|
alpha_clip = material.node_tree.nodes.new("ShaderNodeMath")
|
||||||
|
alpha_clip.name = "RuneWaker Alpha Clip"
|
||||||
|
alpha_clip.operation = "GREATER_THAN"
|
||||||
|
alpha_clip.inputs[1].default_value = 0.5
|
||||||
|
material.node_tree.links.new(alpha_output, alpha_clip.inputs[0])
|
||||||
|
material.node_tree.links.new(alpha_clip.outputs[0], alpha_input)
|
||||||
|
material.blend_method = "CLIP"
|
||||||
|
material.alpha_threshold = 0.5
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]:
|
def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]:
|
||||||
canonical: dict[str, bpy.types.Material] = {}
|
canonical: dict[str, bpy.types.Material] = {}
|
||||||
replacements = 0
|
replacements = 0
|
||||||
@@ -126,7 +190,7 @@ def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]:
|
|||||||
replacements += 1
|
replacements += 1
|
||||||
else:
|
else:
|
||||||
canonical[key] = material
|
canonical[key] = material
|
||||||
material["semanticRole"] = "opaque"
|
material["semanticRole"] = preserve_material_alpha(material)
|
||||||
material["sourceFormat"] = "RuneWaker DDS"
|
material["sourceFormat"] = "RuneWaker DDS"
|
||||||
return {"unique": len(canonical), "replacements": replacements}
|
return {"unique": len(canonical), "replacements": replacements}
|
||||||
|
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ async function cachedResourceAsset(resourceRecord) {
|
|||||||
throw new Error("source ROS is missing: " + projectPath(sourceFile));
|
throw new Error("source ROS is missing: " + projectPath(sourceFile));
|
||||||
}
|
}
|
||||||
const sourceSha256 = await sha256(sourceFile);
|
const sourceSha256 = await sha256(sourceFile);
|
||||||
|
const converterSha256 = await sha256(blenderScript);
|
||||||
const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey);
|
const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey);
|
||||||
const raw = path.join(cacheRoot, "raw");
|
const raw = path.join(cacheRoot, "raw");
|
||||||
const prepared = path.join(cacheRoot, "prepared");
|
const prepared = path.join(cacheRoot, "prepared");
|
||||||
@@ -253,6 +254,7 @@ async function cachedResourceAsset(resourceRecord) {
|
|||||||
.map((file) => path.join(cacheRoot, file));
|
.map((file) => path.join(cacheRoot, file));
|
||||||
if (cached.source?.resource === resourceRecord.normalizedResource
|
if (cached.source?.resource === resourceRecord.normalizedResource
|
||||||
&& cached.source?.sha256 === sourceSha256
|
&& cached.source?.sha256 === sourceSha256
|
||||||
|
&& cached.toolchain?.converterSha256 === converterSha256
|
||||||
&& cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit
|
&& cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit
|
||||||
&& (await Promise.all(outputs.map(exists))).every(Boolean)) {
|
&& (await Promise.all(outputs.map(exists))).every(Boolean)) {
|
||||||
return {
|
return {
|
||||||
@@ -300,6 +302,9 @@ async function cachedResourceAsset(resourceRecord) {
|
|||||||
nativeBridge: recipe.coordinateSystem.nativeBridge,
|
nativeBridge: recipe.coordinateSystem.nativeBridge,
|
||||||
metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit,
|
metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit,
|
||||||
},
|
},
|
||||||
|
toolchain: {
|
||||||
|
converterSha256,
|
||||||
|
},
|
||||||
visual: {
|
visual: {
|
||||||
file: path.relative(
|
file: path.relative(
|
||||||
cacheRoot,
|
cacheRoot,
|
||||||
|
|||||||
+18
-1
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import "./ui/uiLayout.css";
|
||||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||||
import { CombatBridge } from "./game/CombatBridge";
|
import { CombatBridge } from "./game/CombatBridge";
|
||||||
@@ -26,12 +27,27 @@ import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
|
|||||||
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
|
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
|
||||||
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
|
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
|
||||||
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
|
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
|
||||||
|
import { UiLayoutEditor } from "./ui/UiLayoutEditor";
|
||||||
|
import { EditableHudElement } from "./ui/EditableHudElement";
|
||||||
|
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
|
||||||
|
import { actionKeyForEvent, desktopSlotForKey } from "./game/uiLayout";
|
||||||
|
|
||||||
function InputBridge() {
|
function InputBridge() {
|
||||||
const overlay = useGameStore((state) => state.overlay);
|
const overlay = useGameStore((state) => state.overlay);
|
||||||
const companionOpen = useGameStore((state) => state.companionOpen);
|
const companionOpen = useGameStore((state) => state.companionOpen);
|
||||||
|
|
||||||
useEffect(() => installInput({
|
useEffect(() => installInput({
|
||||||
|
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
||||||
|
handleCustomKey: (event) => {
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
if (game.overlay === "ui-layout") return !["Tab", "Enter", "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code);
|
||||||
|
if (currentUiDevice() !== "pc" || game.overlay || game.companionOpen || useCombatStore.getState().pendingGroundTarget) return false;
|
||||||
|
const button = desktopSlotForKey(activeUiLayout(), actionKeyForEvent(event));
|
||||||
|
if (!button) return false;
|
||||||
|
if (!event.repeat && button.abilityId) useCombatStore.getState().castAbility(button.abilityId, game.playerPosition, Date.now());
|
||||||
|
game.setInputMode("keyboard");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
togglePause: () => useGameStore.getState().togglePause(),
|
togglePause: () => useGameStore.getState().togglePause(),
|
||||||
toggleMap: () => useGameStore.getState().toggleMap(),
|
toggleMap: () => useGameStore.getState().toggleMap(),
|
||||||
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
|
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
|
||||||
@@ -98,10 +114,11 @@ export default function GameRuntime() {
|
|||||||
<ManastormBridge />
|
<ManastormBridge />
|
||||||
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
|
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
|
||||||
<Hud />
|
<Hud />
|
||||||
<DungeonInteractionPrompt />
|
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
|
||||||
<MapPanel />
|
<MapPanel />
|
||||||
<GameplayPanels />
|
<GameplayPanels />
|
||||||
<PauseMenu />
|
<PauseMenu />
|
||||||
|
<UiLayoutEditor />
|
||||||
<GmReviewCard />
|
<GmReviewCard />
|
||||||
<GmWorldEditor />
|
<GmWorldEditor />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CharacterProfile, PlayerSession } from "./types";
|
import type { CharacterProfile, PlayerSession } from "./types";
|
||||||
|
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
|
||||||
import {
|
import {
|
||||||
contentCategoryForClass,
|
contentCategoryForClass,
|
||||||
isRomClassId,
|
isRomClassId,
|
||||||
@@ -64,6 +65,7 @@ interface ProfileDatabase {
|
|||||||
accounts: LocalAccount[];
|
accounts: LocalAccount[];
|
||||||
rosters: Record<string, CharacterProfile[]>;
|
rosters: Record<string, CharacterProfile[]>;
|
||||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||||
|
uiPreferencesByOwner?: Record<string, UiPreferences>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountResult {
|
export interface AccountResult {
|
||||||
@@ -202,6 +204,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
|||||||
accounts?: unknown;
|
accounts?: unknown;
|
||||||
rosters?: unknown;
|
rosters?: unknown;
|
||||||
presentationSettingsByOwner?: unknown;
|
presentationSettingsByOwner?: unknown;
|
||||||
|
uiPreferencesByOwner?: unknown;
|
||||||
}) | null;
|
}) | null;
|
||||||
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||||
return emptyDatabase();
|
return emptyDatabase();
|
||||||
@@ -243,7 +246,9 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
|||||||
return [ownerId, normalized];
|
return [ownerId, normalized];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner };
|
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
|
||||||
|
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
|
||||||
|
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||||
if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
|
if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||||
return database;
|
return database;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -254,11 +259,23 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
|||||||
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
|
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
|
||||||
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
|
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
|
||||||
if (!storage || !rosterChangeListener) return;
|
if (!storage || !rosterChangeListener) return;
|
||||||
for (const [ownerId, characters] of Object.entries(database.rosters)) {
|
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
|
||||||
rosterChangeListener(ownerId, characters);
|
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadUiPreferences(ownerId: string, storage: StorageLike | null = browserLocalStorage()): UiPreferences {
|
||||||
|
return normalizeUiPreferences(readDatabase(storage).uiPreferencesByOwner?.[ownerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveUiPreferences(ownerId: string, value: unknown, storage: StorageLike | null = browserLocalStorage()): UiPreferences {
|
||||||
|
const database = readDatabase(storage);
|
||||||
|
const preferences = normalizeUiPreferences(value);
|
||||||
|
database.uiPreferencesByOwner = { ...database.uiPreferencesByOwner, [ownerId]: preferences };
|
||||||
|
writeDatabase(database, storage);
|
||||||
|
return preferences;
|
||||||
|
}
|
||||||
|
|
||||||
function bytesToBase64(bytes: Uint8Array): string {
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
let result = "";
|
let result = "";
|
||||||
for (let index = 0; index < bytes.length; index += 3) {
|
for (let index = 0; index < bytes.length; index += 3) {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Capacitor } from "@capacitor/core";
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import { create } from "zustand";
|
||||||
import {
|
import {
|
||||||
listCharacters,
|
listCharacters,
|
||||||
replaceCharactersForOwner,
|
replaceCharactersForOwner,
|
||||||
setRosterChangeListener,
|
setRosterChangeListener,
|
||||||
|
loadUiPreferences,
|
||||||
|
saveUiPreferences,
|
||||||
type AccountResult,
|
type AccountResult,
|
||||||
} from "./accountRepository";
|
} from "./accountRepository";
|
||||||
import type { CharacterProfile, PlayerSession } from "./types";
|
import type { CharacterProfile, PlayerSession } from "./types";
|
||||||
@@ -20,7 +23,7 @@ interface AuthResponse {
|
|||||||
interface CloudSaveResponse {
|
interface CloudSaveResponse {
|
||||||
revision: number;
|
revision: number;
|
||||||
updatedAt: number | null;
|
updatedAt: number | null;
|
||||||
data: { version?: number; characters?: unknown };
|
data: { version?: number; characters?: unknown; uiPreferences?: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
@@ -39,6 +42,8 @@ let suppressUpload = false;
|
|||||||
let pendingCharacters: CharacterProfile[] | null = null;
|
let pendingCharacters: CharacterProfile[] | null = null;
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let saveQueue = Promise.resolve();
|
let saveQueue = Promise.resolve();
|
||||||
|
export const useCloudSaveStatus = create<{ state: "local" | "pending" | "synced" | "error"; message: string }>(() => ({ state: "local", message: "" }));
|
||||||
|
let saveSequence = 0;
|
||||||
|
|
||||||
export function onlineApiOrigin(): string {
|
export function onlineApiOrigin(): string {
|
||||||
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
|
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
|
||||||
@@ -100,18 +105,24 @@ function flushCloudSave(): void {
|
|||||||
const characters = pendingCharacters;
|
const characters = pendingCharacters;
|
||||||
pendingCharacters = null;
|
pendingCharacters = null;
|
||||||
if (!session?.accessToken || !characters) return;
|
if (!session?.accessToken || !characters) return;
|
||||||
|
const uiPreferences = loadUiPreferences(session.ownerId);
|
||||||
|
const sequence = saveSequence;
|
||||||
saveQueue = saveQueue.then(async () => {
|
saveQueue = saveQueue.then(async () => {
|
||||||
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ data: { version: 1, characters } }),
|
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
|
||||||
}, session.accessToken);
|
}, session.accessToken);
|
||||||
|
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("Healer Man cloud save failed", error);
|
console.error("Healer Man cloud save failed", error);
|
||||||
|
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "error", message: "Saved on this device. Cloud upload failed; retry before switching devices." });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]): void {
|
function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]): void {
|
||||||
if (suppressUpload || !activeSession?.accessToken || activeSession.ownerId !== ownerId) return;
|
if (suppressUpload || !activeSession?.accessToken || activeSession.ownerId !== ownerId) return;
|
||||||
|
saveSequence += 1;
|
||||||
|
useCloudSaveStatus.setState({ state: "pending", message: "Uploading to your account…" });
|
||||||
pendingCharacters = characters.map((character) => ({ ...character }));
|
pendingCharacters = characters.map((character) => ({ ...character }));
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
@@ -123,15 +134,23 @@ function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]
|
|||||||
setRosterChangeListener(queueCloudSave);
|
setRosterChangeListener(queueCloudSave);
|
||||||
|
|
||||||
export function activateOnlineSession(session: PlayerSession): void {
|
export function activateOnlineSession(session: PlayerSession): void {
|
||||||
|
if (activeSession?.ownerId !== session.ownerId && saveTimer) {
|
||||||
|
clearTimeout(saveTimer); saveTimer = null; flushCloudSave();
|
||||||
|
}
|
||||||
activeSession = session.accessToken ? session : null;
|
activeSession = session.accessToken ? session : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function retryCloudSave(): void {
|
||||||
|
if (activeSession) queueCloudSave(activeSession.ownerId, listCharacters(activeSession.ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||||
activateOnlineSession(session);
|
activateOnlineSession(session);
|
||||||
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||||
suppressUpload = true;
|
suppressUpload = true;
|
||||||
try {
|
try {
|
||||||
|
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
|
||||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||||
} finally {
|
} finally {
|
||||||
suppressUpload = false;
|
suppressUpload = false;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { loadUiPreferences, saveUiPreferences, setRosterChangeListener } from "./accountRepository";
|
||||||
|
import { normalizeUiPreferences } from "../game/uiLayout";
|
||||||
|
import type { PlayerSession } from "./types";
|
||||||
|
|
||||||
|
function storage() {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
return { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
|
||||||
|
}
|
||||||
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); setRosterChangeListener(null); });
|
||||||
|
|
||||||
|
describe("profile UI persistence", () => {
|
||||||
|
it("isolates owners and persists templates without requiring a character", () => {
|
||||||
|
const local = storage(); const ui = normalizeUiPreferences(null);
|
||||||
|
ui.slots.pc[1] = { name: "Healer", layout: ui.current.pc };
|
||||||
|
saveUiPreferences("one", ui, local);
|
||||||
|
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
|
||||||
|
expect(loadUiPreferences("two", local).slots.pc[1]).toBeNull();
|
||||||
|
ui.slots.pc[1].name = "Unsaved change";
|
||||||
|
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
|
||||||
|
});
|
||||||
|
it("uploads all six slots and restores them into a separate device cache", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const first = storage(); const second = storage();
|
||||||
|
vi.stubGlobal("window", { localStorage: first });
|
||||||
|
let cloudData: unknown = { version: 1, characters: [] };
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
|
||||||
|
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
}));
|
||||||
|
const client = await import("./onlineAccountClient");
|
||||||
|
const session: PlayerSession = { ownerId: "cloud-user", kind: "account", displayName: "Player", accessToken: "test-token" };
|
||||||
|
client.activateOnlineSession(session);
|
||||||
|
const ui = normalizeUiPreferences(null);
|
||||||
|
ui.slots.pc[2] = { name: "Desktop raid", layout: ui.current.pc };
|
||||||
|
ui.slots.mobile[1] = { name: "On the go", layout: ui.current.mobile };
|
||||||
|
saveUiPreferences(session.ownerId, ui);
|
||||||
|
await vi.advanceTimersByTimeAsync(501);
|
||||||
|
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
|
||||||
|
vi.stubGlobal("window", { localStorage: second });
|
||||||
|
await client.hydrateOnlineSession(session);
|
||||||
|
expect(loadUiPreferences(session.ownerId)).toEqual(ui);
|
||||||
|
expect(loadUiPreferences("different-account").slots.pc[2]).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "aeternal-circle-visual.glb",
|
"fileName": "aeternal-circle-visual.glb",
|
||||||
"size": 4963912,
|
"size": 4963968,
|
||||||
"sha256": "42fdcff1b25b58688943a030f69fd14ea839247a7baabbc31b5f6f7faaf49785",
|
"sha256": "de1ba06b7e3a9237fcff75aff2b00093287975e755cc7e98e77de1b7af6a9c14",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "aeternal-circle-visual.optimized.glb",
|
"file": "aeternal-circle-visual.optimized.glb",
|
||||||
"size": 4963912,
|
"size": 4963968,
|
||||||
"checksum": "42fdcff1b25b58688943a030f69fd14ea839247a7baabbc31b5f6f7faaf49785"
|
"checksum": "de1ba06b7e3a9237fcff75aff2b00093287975e755cc7e98e77de1b7af6a9c14"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "barren-caves-visual.glb",
|
"fileName": "barren-caves-visual.glb",
|
||||||
"size": 1710152,
|
"size": 1710188,
|
||||||
"sha256": "d0f733de4b26c0f432c5e160614765f320bf1753a829be269740b340e7415a12",
|
"sha256": "e6dc49e6aa6fd20cee1b2afa43324a714f8f8284a6becef8dc514509b432f9bb",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "barren-caves-visual.optimized.glb",
|
"file": "barren-caves-visual.optimized.glb",
|
||||||
"size": 1710152,
|
"size": 1710188,
|
||||||
"checksum": "d0f733de4b26c0f432c5e160614765f320bf1753a829be269740b340e7415a12"
|
"checksum": "e6dc49e6aa6fd20cee1b2afa43324a714f8f8284a6becef8dc514509b432f9bb"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "cave-water-dragon-visual.glb",
|
"fileName": "cave-water-dragon-visual.glb",
|
||||||
"size": 8893112,
|
"size": 8893204,
|
||||||
"sha256": "cc57c0285de77690c4a1cdabaecd8b192791cbd05aca38aa21ed96462688ca31",
|
"sha256": "dd1887f88d6ee9102da0e34ea2b4087742e37a2b8ce779c722bc124effe1faca",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "cave-water-dragon-visual.optimized.glb",
|
"file": "cave-water-dragon-visual.optimized.glb",
|
||||||
"size": 8893112,
|
"size": 8893204,
|
||||||
"checksum": "cc57c0285de77690c4a1cdabaecd8b192791cbd05aca38aa21ed96462688ca31"
|
"checksum": "dd1887f88d6ee9102da0e34ea2b4087742e37a2b8ce779c722bc124effe1faca"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "demon-stronghold-125-visual.glb",
|
"fileName": "demon-stronghold-125-visual.glb",
|
||||||
"size": 15134500,
|
"size": 15135060,
|
||||||
"sha256": "ecbacfe274e7eecb104c5807eedf3cc51cd4b7a628967c6331fc93b86396b842",
|
"sha256": "e2106115dcc216999d2e37d26eeb52844ce4f41b44e19594699f37a54d7f19b9",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "demon-stronghold-125-visual.optimized.glb",
|
"file": "demon-stronghold-125-visual.optimized.glb",
|
||||||
"size": 15134500,
|
"size": 15135060,
|
||||||
"checksum": "ecbacfe274e7eecb104c5807eedf3cc51cd4b7a628967c6331fc93b86396b842"
|
"checksum": "e2106115dcc216999d2e37d26eeb52844ce4f41b44e19594699f37a54d7f19b9"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "demon-stronghold-visual.glb",
|
"fileName": "demon-stronghold-visual.glb",
|
||||||
"size": 15166984,
|
"size": 15167544,
|
||||||
"sha256": "9368984b3c183a8db6e8b7e18b7818edcf41b8edd45ffeca1d1df28aadc592a2",
|
"sha256": "664181dc790b9e68cdc7451dbd0b1170a033a71ca19375743b6e66ca25889723",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "demon-stronghold-visual.optimized.glb",
|
"file": "demon-stronghold-visual.optimized.glb",
|
||||||
"size": 15166984,
|
"size": 15167544,
|
||||||
"checksum": "9368984b3c183a8db6e8b7e18b7818edcf41b8edd45ffeca1d1df28aadc592a2"
|
"checksum": "664181dc790b9e68cdc7451dbd0b1170a033a71ca19375743b6e66ca25889723"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "dungeon-of-dalanis-visual.glb",
|
"fileName": "dungeon-of-dalanis-visual.glb",
|
||||||
"size": 11936864,
|
"size": 11937088,
|
||||||
"sha256": "4c584ef34779e516f8e6ff3a4907eaab1c7c8d49d4c302de66e17b09222d8829",
|
"sha256": "45cad819bc348dec8926f7de237c625e9d18697b4200804e0440dd16b8976959",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "dungeon-of-dalanis-visual.optimized.glb",
|
"file": "dungeon-of-dalanis-visual.optimized.glb",
|
||||||
"size": 11936864,
|
"size": 11937088,
|
||||||
"checksum": "4c584ef34779e516f8e6ff3a4907eaab1c7c8d49d4c302de66e17b09222d8829"
|
"checksum": "45cad819bc348dec8926f7de237c625e9d18697b4200804e0440dd16b8976959"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -284,8 +284,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "forsaken-abbey-visual.glb",
|
"fileName": "forsaken-abbey-visual.glb",
|
||||||
"size": 9580212,
|
"size": 9580568,
|
||||||
"sha256": "37800c26cdf784658a57dcc7328ab1b9b23e46d018be6d1e6420f127768b0332",
|
"sha256": "ee7afd5e9179e6bcb7ec9171798466282e09d4ef696a3f9639bcd2bbc2ae8f48",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "forsaken-abbey-visual.optimized.glb",
|
"file": "forsaken-abbey-visual.optimized.glb",
|
||||||
"size": 9580212,
|
"size": 9580568,
|
||||||
"checksum": "37800c26cdf784658a57dcc7328ab1b9b23e46d018be6d1e6420f127768b0332"
|
"checksum": "ee7afd5e9179e6bcb7ec9171798466282e09d4ef696a3f9639bcd2bbc2ae8f48"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "grafu-castle-visual.glb",
|
"fileName": "grafu-castle-visual.glb",
|
||||||
"size": 11149716,
|
"size": 11150124,
|
||||||
"sha256": "ba4552484ef9cddc1c00607d0dfd4c8fb0bae5b3cd3f992d215a580dac1e96ed",
|
"sha256": "b775778f457d639e3f21982aaa6bd3ee1b0eba951c711d1ba319b0588e24a38f",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "grafu-castle-visual.optimized.glb",
|
"file": "grafu-castle-visual.optimized.glb",
|
||||||
"size": 11149716,
|
"size": 11150124,
|
||||||
"checksum": "ba4552484ef9cddc1c00607d0dfd4c8fb0bae5b3cd3f992d215a580dac1e96ed"
|
"checksum": "b775778f457d639e3f21982aaa6bd3ee1b0eba951c711d1ba319b0588e24a38f"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "hall-of-survivors-visual.glb",
|
"fileName": "hall-of-survivors-visual.glb",
|
||||||
"size": 15216768,
|
"size": 15216936,
|
||||||
"sha256": "18b9d116da599159719ad76eeee45a4d89056ee8c61cc4f21cab9a1d9228fff7",
|
"sha256": "96b91b24462b8428e8808295dcea29d26de9be8431ea6667dde92cdded4ddf2a",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "hall-of-survivors-visual.optimized.glb",
|
"file": "hall-of-survivors-visual.optimized.glb",
|
||||||
"size": 15216768,
|
"size": 15216936,
|
||||||
"checksum": "18b9d116da599159719ad76eeee45a4d89056ee8c61cc4f21cab9a1d9228fff7"
|
"checksum": "96b91b24462b8428e8808295dcea29d26de9be8431ea6667dde92cdded4ddf2a"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
@@ -3,7 +3,7 @@
|
|||||||
"dungeonId": "hoto",
|
"dungeonId": "hoto",
|
||||||
"title": "HOTO",
|
"title": "HOTO",
|
||||||
"status": "source-packaged",
|
"status": "source-packaged",
|
||||||
"generatedAt": "2026-08-01T01:00:34.655Z",
|
"generatedAt": "2026-09-09T19:39:49.407Z",
|
||||||
"source": {
|
"source": {
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/hoto.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/hoto.json",
|
||||||
"wdb": "Resource/wdb/dungeon/dgn_echoes_of_the_sea.wdb",
|
"wdb": "Resource/wdb/dungeon/dgn_echoes_of_the_sea.wdb",
|
||||||
@@ -261,33 +261,37 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "hoto-visual.glb",
|
"fileName": "hoto-visual.glb",
|
||||||
"size": 6667208,
|
"relativeFile": "hoto-visual.glb",
|
||||||
"sha256": "0d12b9c6dc477704d2833c81f76113d340da7f07f5885a40bafd1321524e455e",
|
"size": 6710184,
|
||||||
"compression": "meshopt+ktx2"
|
"sha256": "beff3a180c55166d5ebd7e3610e65b59f8104edfd7596aa22ac3a49c578fcdc0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
"fileName": "hoto-collision-000.glb",
|
"fileName": "hoto-collision-000.glb",
|
||||||
|
"relativeFile": "hoto-collision-000.glb",
|
||||||
"size": 3088036,
|
"size": 3088036,
|
||||||
"sha256": "3116529f2d262ff4cab7997e852f9d15caacab6298921360b49dcafd52b8c475"
|
"sha256": "3116529f2d262ff4cab7997e852f9d15caacab6298921360b49dcafd52b8c475"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
"fileName": "hoto-collision-001.glb",
|
"fileName": "hoto-collision-001.glb",
|
||||||
|
"relativeFile": "hoto-collision-001.glb",
|
||||||
"size": 2989324,
|
"size": 2989324,
|
||||||
"sha256": "1f5cbd4b04b5a060029fd7c023ee19db5d8646566fcd01b931473b5b13c13b9f"
|
"sha256": "1f5cbd4b04b5a060029fd7c023ee19db5d8646566fcd01b931473b5b13c13b9f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
"fileName": "hoto-collision-002.glb",
|
"fileName": "hoto-collision-002.glb",
|
||||||
|
"relativeFile": "hoto-collision-002.glb",
|
||||||
"size": 526648,
|
"size": 526648,
|
||||||
"sha256": "0a468a6ef30941c04a20456866f0593f7e1f9f9bf8dc0024176d2128975f1879"
|
"sha256": "0a468a6ef30941c04a20456866f0593f7e1f9f9bf8dc0024176d2128975f1879"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "navigation",
|
"role": "navigation",
|
||||||
"fileName": "hoto-navigation.glb",
|
"fileName": "hoto-navigation.glb",
|
||||||
|
"relativeFile": "hoto-navigation.glb",
|
||||||
"size": 482260,
|
"size": 482260,
|
||||||
"sha256": "2cd78e6d8f52cb6488747a1f87cb688eedb77ccf412fb50674c25f70306477fd"
|
"sha256": "a2bde2e4b8ed0d0bac8c4669a05192ff0a35ae9c64df0c0a29fe3735b65cbf4b"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"review": {
|
"review": {
|
||||||
@@ -300,6 +304,6 @@
|
|||||||
"exceptions": [
|
"exceptions": [
|
||||||
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
||||||
"Collision initially includes decorative primary-ROS geometry.",
|
"Collision initially includes decorative primary-ROS geometry.",
|
||||||
"Original skeletal animations and complex scripted mechanics remain later passes."
|
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,15 +4,11 @@
|
|||||||
"status": "review-required",
|
"status": "review-required",
|
||||||
"source": {
|
"source": {
|
||||||
"file": "hoto-visual.glb",
|
"file": "hoto-visual.glb",
|
||||||
"size": 26313948
|
"size": 26314004
|
||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "hoto-visual.optimized.glb",
|
"file": "hoto-visual.optimized.glb",
|
||||||
"size": 6667208,
|
"size": 6710184
|
||||||
"checksum": "0d12b9c6dc477704d2833c81f76113d340da7f07f5885a40bafd1321524e455e"
|
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2"
|
||||||
"commands": [
|
|
||||||
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"sourceWdb": "D:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_echoes_of_the_sea.wdb",
|
"sourceWdb": "F:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_echoes_of_the_sea.wdb",
|
||||||
"coordinateSystem": "runewaker-left-handed-y-up",
|
"coordinateSystem": "runewaker-left-handed-y-up",
|
||||||
"descriptorCount": 1627,
|
"descriptorCount": 1627,
|
||||||
"descriptors": [
|
"descriptors": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "ice-dwarf-kingdom-visual.glb",
|
"fileName": "ice-dwarf-kingdom-visual.glb",
|
||||||
"size": 9880192,
|
"size": 9880304,
|
||||||
"sha256": "a6b902e85bb6026f18281d77efa235ec880ffc2fc13ea6bfdc431d24af768e00",
|
"sha256": "41cef8ce1bf6dbf7df0c4e2797a325e4d50a3870d435d46684600894d4aa92e7",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "ice-dwarf-kingdom-visual.optimized.glb",
|
"file": "ice-dwarf-kingdom-visual.optimized.glb",
|
||||||
"size": 9880192,
|
"size": 9880304,
|
||||||
"checksum": "a6b902e85bb6026f18281d77efa235ec880ffc2fc13ea6bfdc431d24af768e00"
|
"checksum": "41cef8ce1bf6dbf7df0c4e2797a325e4d50a3870d435d46684600894d4aa92e7"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "kal-turok-hive-visual.glb",
|
"fileName": "kal-turok-hive-visual.glb",
|
||||||
"size": 1777616,
|
"size": 1777728,
|
||||||
"sha256": "012867deefc4dc5a18c3e1a507309aa48f119cf4c2e88968fdd2787936ed7c24",
|
"sha256": "75c0c596218cec07003e2def1d74c8e1f5ca3010ed370db558a1786b003f4f1c",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "kal-turok-hive-visual.optimized.glb",
|
"file": "kal-turok-hive-visual.optimized.glb",
|
||||||
"size": 1777616,
|
"size": 1777728,
|
||||||
"checksum": "012867deefc4dc5a18c3e1a507309aa48f119cf4c2e88968fdd2787936ed7c24"
|
"checksum": "75c0c596218cec07003e2def1d74c8e1f5ca3010ed370db558a1786b003f4f1c"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"dungeonId": "kalins-shrine",
|
"dungeonId": "kalins-shrine",
|
||||||
"title": "Kalin's Shrine",
|
"title": "Kalin's Shrine",
|
||||||
"status": "source-packaged",
|
"status": "source-packaged",
|
||||||
"generatedAt": "2026-08-01T00:57:43.432Z",
|
"generatedAt": "2026-09-09T18:51:01.674Z",
|
||||||
"source": {
|
"source": {
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine.json",
|
||||||
"wdb": "Resource/wdb/dungeon/dgn_kleeon_temple.wdb",
|
"wdb": "Resource/wdb/dungeon/dgn_kleeon_temple.wdb",
|
||||||
@@ -277,33 +277,37 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "kalins-shrine-visual.glb",
|
"fileName": "kalins-shrine-visual.glb",
|
||||||
"size": 8747072,
|
"relativeFile": "kalins-shrine-visual.glb",
|
||||||
"sha256": "769accb48e23c9f80e28697e3e8454ef4148243a69ce247ed06ba4eb35bb0da7",
|
"size": 8851724,
|
||||||
"compression": "meshopt+ktx2"
|
"sha256": "023c43a56253e97fa0526185c61f76e0e9fa8398b7a35771728566c331cd22a9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
"fileName": "kalins-shrine-collision-000.glb",
|
"fileName": "kalins-shrine-collision-000.glb",
|
||||||
|
"relativeFile": "kalins-shrine-collision-000.glb",
|
||||||
"size": 4748976,
|
"size": 4748976,
|
||||||
"sha256": "ed1e713c159e024b4786ef8befadbfedd3849ba580e7f49970e2785b6f4fff7d"
|
"sha256": "ed1e713c159e024b4786ef8befadbfedd3849ba580e7f49970e2785b6f4fff7d"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
"fileName": "kalins-shrine-collision-001.glb",
|
"fileName": "kalins-shrine-collision-001.glb",
|
||||||
|
"relativeFile": "kalins-shrine-collision-001.glb",
|
||||||
"size": 4555800,
|
"size": 4555800,
|
||||||
"sha256": "5fea14212353593c8daced2e303f88dae010f8f73aedb9890a3eee67eb8dd7cd"
|
"sha256": "5fea14212353593c8daced2e303f88dae010f8f73aedb9890a3eee67eb8dd7cd"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
"fileName": "kalins-shrine-collision-002.glb",
|
"fileName": "kalins-shrine-collision-002.glb",
|
||||||
|
"relativeFile": "kalins-shrine-collision-002.glb",
|
||||||
"size": 1215728,
|
"size": 1215728,
|
||||||
"sha256": "e51d48d1f4521f1a4722fd4c528a90bdfd2978a87d2ace0dd662f23475490565"
|
"sha256": "e51d48d1f4521f1a4722fd4c528a90bdfd2978a87d2ace0dd662f23475490565"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "navigation",
|
"role": "navigation",
|
||||||
"fileName": "kalins-shrine-navigation.glb",
|
"fileName": "kalins-shrine-navigation.glb",
|
||||||
|
"relativeFile": "kalins-shrine-navigation.glb",
|
||||||
"size": 327792,
|
"size": 327792,
|
||||||
"sha256": "37fd7f8efac36f83279efbfb33df37646453af214db29597bd14c8e9f19ddc04"
|
"sha256": "6b77496f7b46890d401f955f9254ae37d25d9a1287a3bc9e787e9a254f9d9071"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"review": {
|
"review": {
|
||||||
@@ -316,6 +320,6 @@
|
|||||||
"exceptions": [
|
"exceptions": [
|
||||||
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
||||||
"Collision initially includes decorative primary-ROS geometry.",
|
"Collision initially includes decorative primary-ROS geometry.",
|
||||||
"Original skeletal animations and complex scripted mechanics remain later passes."
|
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 892 KiB |
Binary file not shown.
@@ -4,15 +4,11 @@
|
|||||||
"status": "review-required",
|
"status": "review-required",
|
||||||
"source": {
|
"source": {
|
||||||
"file": "kalins-shrine-visual.glb",
|
"file": "kalins-shrine-visual.glb",
|
||||||
"size": 26551164
|
"size": 26551296
|
||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "kalins-shrine-visual.optimized.glb",
|
"file": "kalins-shrine-visual.optimized.glb",
|
||||||
"size": 8747072,
|
"size": 8851724
|
||||||
"checksum": "769accb48e23c9f80e28697e3e8454ef4148243a69ce247ed06ba4eb35bb0da7"
|
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2"
|
||||||
"commands": [
|
|
||||||
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"sourceWdb": "D:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_kleeon_temple.wdb",
|
"sourceWdb": "F:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_kleeon_temple.wdb",
|
||||||
"coordinateSystem": "runewaker-left-handed-y-up",
|
"coordinateSystem": "runewaker-left-handed-y-up",
|
||||||
"descriptorCount": 943,
|
"descriptorCount": 943,
|
||||||
"descriptors": [
|
"descriptors": [
|
||||||
|
|||||||
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "kawaks-tomb-visual.glb",
|
"fileName": "kawaks-tomb-visual.glb",
|
||||||
"size": 8963188,
|
"size": 8963376,
|
||||||
"sha256": "de54d223725e2c3dfb0c47a97c208a212ef9423860bd0ac390f1482b302d3a32",
|
"sha256": "3f1755780540c54a1ba9b528e646ebd39b12ab127e41fa2d497fd7749d202018",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "kawaks-tomb-visual.optimized.glb",
|
"file": "kawaks-tomb-visual.optimized.glb",
|
||||||
"size": 8963188,
|
"size": 8963376,
|
||||||
"checksum": "de54d223725e2c3dfb0c47a97c208a212ef9423860bd0ac390f1482b302d3a32"
|
"checksum": "3f1755780540c54a1ba9b528e646ebd39b12ab127e41fa2d497fd7749d202018"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "kulech-bones-nest-visual.glb",
|
"fileName": "kulech-bones-nest-visual.glb",
|
||||||
"size": 12146892,
|
"size": 12147008,
|
||||||
"sha256": "2d912dcf7c0c8e59e36362fd947df1d5554c5dd5060590e1abb112e4c39f606f",
|
"sha256": "b06b42b3c7f19abf9d645eec6be78f868cf6966e0b472e2ca4a1c36d3cf4d2d1",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "kulech-bones-nest-visual.optimized.glb",
|
"file": "kulech-bones-nest-visual.optimized.glb",
|
||||||
"size": 12146892,
|
"size": 12147008,
|
||||||
"checksum": "2d912dcf7c0c8e59e36362fd947df1d5554c5dd5060590e1abb112e4c39f606f"
|
"checksum": "b06b42b3c7f19abf9d645eec6be78f868cf6966e0b472e2ca4a1c36d3cf4d2d1"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "lair-demon-dragon-visual.glb",
|
"fileName": "lair-demon-dragon-visual.glb",
|
||||||
"size": 4551300,
|
"size": 4551356,
|
||||||
"sha256": "d28481504802203e800985836f2b0907d67f6d6d0495c67500914e749d771ffe",
|
"sha256": "b371465e96ef978915c9a515846ecf9dccb98607fd549617a1c71f45468fa5a7",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "lair-demon-dragon-visual.optimized.glb",
|
"file": "lair-demon-dragon-visual.optimized.glb",
|
||||||
"size": 4551300,
|
"size": 4551356,
|
||||||
"checksum": "d28481504802203e800985836f2b0907d67f6d6d0495c67500914e749d771ffe"
|
"checksum": "b371465e96ef978915c9a515846ecf9dccb98607fd549617a1c71f45468fa5a7"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "mystic-altar-visual.glb",
|
"fileName": "mystic-altar-visual.glb",
|
||||||
"size": 5552872,
|
"size": 5552892,
|
||||||
"sha256": "765dd1b15bbe79fe1ffc2104e10caece19acaf86c919f0327419cb3609dbf600",
|
"sha256": "c37f27643598c8fe6401828be2950bc08f299dfe491df37736fda4662c70a96e",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "mystic-altar-visual.optimized.glb",
|
"file": "mystic-altar-visual.optimized.glb",
|
||||||
"size": 5552872,
|
"size": 5552892,
|
||||||
"checksum": "765dd1b15bbe79fe1ffc2104e10caece19acaf86c919f0327419cb3609dbf600"
|
"checksum": "c37f27643598c8fe6401828be2950bc08f299dfe491df37736fda4662c70a96e"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"dungeonId": "paspers-shrine",
|
"dungeonId": "paspers-shrine",
|
||||||
"title": "Pasper's Shrine",
|
"title": "Pasper's Shrine",
|
||||||
"status": "source-packaged",
|
"status": "source-packaged",
|
||||||
"generatedAt": "2026-08-01T01:00:57.399Z",
|
"generatedAt": "2026-09-09T19:19:45.719Z",
|
||||||
"source": {
|
"source": {
|
||||||
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine.json",
|
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine.json",
|
||||||
"wdb": "Resource/wdb/dungeon/dgn_borsburs.wdb",
|
"wdb": "Resource/wdb/dungeon/dgn_borsburs.wdb",
|
||||||
@@ -137,27 +137,30 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "paspers-shrine-visual.glb",
|
"fileName": "paspers-shrine-visual.glb",
|
||||||
"size": 5625952,
|
"relativeFile": "paspers-shrine-visual.glb",
|
||||||
"sha256": "82ea6cafd19c0a323c994795699223a07193807da29b300eb12dac41146f1034",
|
"size": 5674908,
|
||||||
"compression": "meshopt+ktx2"
|
"sha256": "bcf6443e8adb8b7f7dd81f4a97b79aac1c99756a83289bbada31853d2d9adb7c"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
"fileName": "paspers-shrine-collision-000.glb",
|
"fileName": "paspers-shrine-collision-000.glb",
|
||||||
|
"relativeFile": "paspers-shrine-collision-000.glb",
|
||||||
"size": 4291972,
|
"size": 4291972,
|
||||||
"sha256": "b6efbe1b90545dd2c2af3bfc7396616147211f6f79b999ccf81ca3680fdc984e"
|
"sha256": "b6efbe1b90545dd2c2af3bfc7396616147211f6f79b999ccf81ca3680fdc984e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
"fileName": "paspers-shrine-collision-001.glb",
|
"fileName": "paspers-shrine-collision-001.glb",
|
||||||
|
"relativeFile": "paspers-shrine-collision-001.glb",
|
||||||
"size": 4479896,
|
"size": 4479896,
|
||||||
"sha256": "1c6f52d7e21171acfbe3fb43335b2c7f3de13217881840947f15fd0722adb4de"
|
"sha256": "1c6f52d7e21171acfbe3fb43335b2c7f3de13217881840947f15fd0722adb4de"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "navigation",
|
"role": "navigation",
|
||||||
"fileName": "paspers-shrine-navigation.glb",
|
"fileName": "paspers-shrine-navigation.glb",
|
||||||
|
"relativeFile": "paspers-shrine-navigation.glb",
|
||||||
"size": 152356,
|
"size": 152356,
|
||||||
"sha256": "e05fb0b7a3c86bba3ac46606365169dea6c575281b2bb15707addcedb279f108"
|
"sha256": "4ebba3fb97a99df49c7301dbfcdb8154634cf4e56a85c5ed587c7515221d0a10"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"review": {
|
"review": {
|
||||||
@@ -170,6 +173,6 @@
|
|||||||
"exceptions": [
|
"exceptions": [
|
||||||
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
||||||
"Collision initially includes decorative primary-ROS geometry.",
|
"Collision initially includes decorative primary-ROS geometry.",
|
||||||
"Original skeletal animations and complex scripted mechanics remain later passes."
|
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -4,15 +4,11 @@
|
|||||||
"status": "review-required",
|
"status": "review-required",
|
||||||
"source": {
|
"source": {
|
||||||
"file": "paspers-shrine-visual.glb",
|
"file": "paspers-shrine-visual.glb",
|
||||||
"size": 14723576
|
"size": 14723652
|
||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "paspers-shrine-visual.optimized.glb",
|
"file": "paspers-shrine-visual.optimized.glb",
|
||||||
"size": 5625952,
|
"size": 5674908
|
||||||
"checksum": "82ea6cafd19c0a323c994795699223a07193807da29b300eb12dac41146f1034"
|
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2"
|
||||||
"commands": [
|
|
||||||
"node scripts/asset-pipeline/compress-ktx2.mjs <optimized.glb> --concurrency 1 --jobs 2"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1024 KiB After Width: | Height: | Size: 1024 KiB |
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"sourceWdb": "D:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_borsburs.wdb",
|
"sourceWdb": "F:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_borsburs.wdb",
|
||||||
"coordinateSystem": "runewaker-left-handed-y-up",
|
"coordinateSystem": "runewaker-left-handed-y-up",
|
||||||
"descriptorCount": 564,
|
"descriptorCount": 564,
|
||||||
"descriptors": [
|
"descriptors": [
|
||||||
|
|||||||
@@ -149,8 +149,8 @@
|
|||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "raksha-temple-visual.glb",
|
"fileName": "raksha-temple-visual.glb",
|
||||||
"relativeFile": "raksha-temple-visual.glb",
|
"relativeFile": "raksha-temple-visual.glb",
|
||||||
"size": 23554528,
|
"size": 23556584,
|
||||||
"sha256": "3a61098e772a0c656aa25d88aee357f78b84c738e45150fca6fd5059c2b675f7"
|
"sha256": "1fad5149acfce870be822f5355369f15dd76fba280bbeda31ff7f371c442082f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "raksha-temple-visual.optimized.glb",
|
"file": "raksha-temple-visual.optimized.glb",
|
||||||
"size": 23554528
|
"size": 23556584
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "sardo-castle-visual.glb",
|
"fileName": "sardo-castle-visual.glb",
|
||||||
"size": 7756568,
|
"size": 7756828,
|
||||||
"sha256": "16d759af9f8a3f98281e89362c7fb52dceff6543bbd837a7c6f89015920cdd94",
|
"sha256": "191396625e301a41afab38e168848a198a9be5918a071dadda841683a7c6261e",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "sardo-castle-visual.optimized.glb",
|
"file": "sardo-castle-visual.optimized.glb",
|
||||||
"size": 7756568,
|
"size": 7756828,
|
||||||
"checksum": "16d759af9f8a3f98281e89362c7fb52dceff6543bbd837a7c6f89015920cdd94"
|
"checksum": "191396625e301a41afab38e168848a198a9be5918a071dadda841683a7c6261e"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -149,8 +149,8 @@
|
|||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "the-origin-visual.glb",
|
"fileName": "the-origin-visual.glb",
|
||||||
"relativeFile": "the-origin-visual.glb",
|
"relativeFile": "the-origin-visual.glb",
|
||||||
"size": 28683180,
|
"size": 28684736,
|
||||||
"sha256": "d1ded0316b2cbaa07458c56864c47c01afaa85e4f33dd13f5d95984d7305fb92"
|
"sha256": "1f96c2b57a24470a003834a8c91cb0ceda5b01824d5bfa31904afd247c404e88"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"role": "collision",
|
"role": "collision",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "the-origin-visual.optimized.glb",
|
"file": "the-origin-visual.optimized.glb",
|
||||||
"size": 28683180
|
"size": 28684736
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "tomb-seven-heroes-visual.glb",
|
"fileName": "tomb-seven-heroes-visual.glb",
|
||||||
"size": 10910804,
|
"size": 10911104,
|
||||||
"sha256": "d9f39da6b61c9f841c5d075e4985bc67e533ce422313fdf38d5f6c99dbb304c1",
|
"sha256": "09250a1fbabf8a03f922c9a3950aee3daa1edc54215cd03ddc9bf5c96e1429bd",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "tomb-seven-heroes-visual.optimized.glb",
|
"file": "tomb-seven-heroes-visual.optimized.glb",
|
||||||
"size": 10910804,
|
"size": 10911104,
|
||||||
"checksum": "d9f39da6b61c9f841c5d075e4985bc67e533ce422313fdf38d5f6c99dbb304c1"
|
"checksum": "09250a1fbabf8a03f922c9a3950aee3daa1edc54215cd03ddc9bf5c96e1429bd"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "treasure-trove-visual.glb",
|
"fileName": "treasure-trove-visual.glb",
|
||||||
"size": 7433280,
|
"size": 7433780,
|
||||||
"sha256": "f4c4dc12df65f8afc3c696308db17cb42b6a5894086dbe870dae2e21e1258448",
|
"sha256": "c56d429187fd2ffbe20e11d5e7bbfd2128a779c9251fa2ab4b12286caf0de0b6",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "treasure-trove-visual.optimized.glb",
|
"file": "treasure-trove-visual.optimized.glb",
|
||||||
"size": 7433280,
|
"size": 7433780,
|
||||||
"checksum": "f4c4dc12df65f8afc3c696308db17cb42b6a5894086dbe870dae2e21e1258448"
|
"checksum": "c56d429187fd2ffbe20e11d5e7bbfd2128a779c9251fa2ab4b12286caf0de0b6"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "warnorken-arena-visual.glb",
|
"fileName": "warnorken-arena-visual.glb",
|
||||||
"size": 3752136,
|
"size": 3752248,
|
||||||
"sha256": "c452a843c2e4123d4fe0d20da41558f5b5a1906548b3d091dc691ffc42ed19d9",
|
"sha256": "ee81c9e7c94d624977143297ca6c070c40799fdda1c51b92044c70dff53987ea",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "warnorken-arena-visual.optimized.glb",
|
"file": "warnorken-arena-visual.optimized.glb",
|
||||||
"size": 3752136,
|
"size": 3752248,
|
||||||
"checksum": "c452a843c2e4123d4fe0d20da41558f5b5a1906548b3d091dc691ffc42ed19d9"
|
"checksum": "ee81c9e7c94d624977143297ca6c070c40799fdda1c51b92044c70dff53987ea"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "ystra-labyrinth-boss1-visual.glb",
|
"fileName": "ystra-labyrinth-boss1-visual.glb",
|
||||||
"size": 2709352,
|
"size": 2709464,
|
||||||
"sha256": "c8ca72a5fdf487fa71a1aecd1e66ea821183f476540be9a89444b409be957c25",
|
"sha256": "5e35eeccf5e207477bfb3a38dee75d53a5c5467433a90270b79a9ba8619a6260",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "ystra-labyrinth-boss1-visual.optimized.glb",
|
"file": "ystra-labyrinth-boss1-visual.optimized.glb",
|
||||||
"size": 2709352,
|
"size": 2709464,
|
||||||
"checksum": "c8ca72a5fdf487fa71a1aecd1e66ea821183f476540be9a89444b409be957c25"
|
"checksum": "5e35eeccf5e207477bfb3a38dee75d53a5c5467433a90270b79a9ba8619a6260"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
@@ -137,8 +137,8 @@
|
|||||||
{
|
{
|
||||||
"role": "visual",
|
"role": "visual",
|
||||||
"fileName": "ystra-labyrinth-boss2-visual.glb",
|
"fileName": "ystra-labyrinth-boss2-visual.glb",
|
||||||
"size": 2682688,
|
"size": 2682800,
|
||||||
"sha256": "d52524621220e16d844badc24ee745f4374798031b2b0bc7236decbe3b9588ab",
|
"sha256": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a",
|
||||||
"compression": "meshopt+ktx2"
|
"compression": "meshopt+ktx2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
},
|
},
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"file": "ystra-labyrinth-boss2-visual.optimized.glb",
|
"file": "ystra-labyrinth-boss2-visual.optimized.glb",
|
||||||
"size": 2682688,
|
"size": 2682800,
|
||||||
"checksum": "d52524621220e16d844badc24ee745f4374798031b2b0bc7236decbe3b9588ab"
|
"checksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a"
|
||||||
},
|
},
|
||||||
"compression": "meshopt+ktx2",
|
"compression": "meshopt+ktx2",
|
||||||
"commands": [
|
"commands": [
|
||||||
|
|||||||
Binary file not shown.
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user