psx map standalone exporter
This commit is contained in:
parent
a9153546ae
commit
2f243976b6
16 changed files with 3254 additions and 5 deletions
3
psx-map-exporter/.gitignore
vendored
Normal file
3
psx-map-exporter/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.cache/**
|
||||
.output/**
|
||||
node_modules/**
|
||||
248
psx-map-exporter/docs/implementation-analysis.md
Normal file
248
psx-map-exporter/docs/implementation-analysis.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# PSX Map Exporter Implementation Analysis
|
||||
|
||||
## Summary
|
||||
|
||||
The exporter should be treated as a controlled probe, not as a final renderer.
|
||||
|
||||
The key design choice is to keep the whole path raw-file-based and auditable:
|
||||
|
||||
- raw WDL in
|
||||
- explicit carve + record extraction + bundle extraction
|
||||
- cached sprite/frame artifacts out
|
||||
- final composed map PNG out
|
||||
|
||||
That keeps the work independent from the existing viewer and makes every wrong assumption inspectable.
|
||||
|
||||
## Why This Architecture
|
||||
|
||||
The existing PSX work already proved two important negative results:
|
||||
|
||||
- direct raw bundle-order art binding is too weak to count as solved
|
||||
- viewer-side polish is low value until extraction is isolated and testable
|
||||
|
||||
So the new exporter should optimize for:
|
||||
|
||||
- small number of assumptions
|
||||
- easy intermediate inspection
|
||||
- direct correspondence to documented executable behavior where possible
|
||||
|
||||
## Chosen `v0` Path
|
||||
|
||||
### 1. Parse only the parts of the WDL we can justify now
|
||||
|
||||
Implemented directly from docs:
|
||||
|
||||
- `0x34` header
|
||||
- audio-size dword
|
||||
- absolute region boundaries recovered from high offset words in the header
|
||||
|
||||
Not implemented in `v0`:
|
||||
|
||||
- full loader section choreography
|
||||
- detached runtime stream install
|
||||
- inflated runtime-state interpretation
|
||||
|
||||
Those are preserved as future extension points but not required for the first PNG.
|
||||
|
||||
### 2. Prefer loader-sized `post_audio_section_00` as a layered authored probe
|
||||
|
||||
Why:
|
||||
|
||||
- the old region00-first path is now known to overfit the small root-dispatch family
|
||||
- loader-sized section parsing recovers the dense constructor-placement records from the same first real section, currently modeled as paired 12-byte records inside 24-byte row chunks
|
||||
- the same section also exposes the smaller root-dispatch lane, which is independently renderable offline and now belongs in the default layered probe
|
||||
|
||||
Tradeoff:
|
||||
|
||||
- the art binding is still diagnostic-only for many types
|
||||
- constructor placements are better understood as one runtime object seed layer, not the final visible map or the static world substrate
|
||||
- root-dispatch rows now render as a second authored layer, but they still do not close the runtime-only control, state, and dynamic effect gaps
|
||||
|
||||
This is acceptable for `v0` because the project goal is a fresh, inspectable layered baseline rather than a falsely confident full renderer.
|
||||
|
||||
### 3. Decode art from raw bundles, but keep binding diagnostic
|
||||
|
||||
What is strong already:
|
||||
|
||||
- bundle scan can be constrained by executable-backed header fields
|
||||
- frame decode and row-RLE semantics are pinned
|
||||
|
||||
What is still weak:
|
||||
|
||||
- exact late-`DAT_800758d8` parse and type-to-resource selection path
|
||||
- exact palette path
|
||||
|
||||
So the current standalone probe does the right split:
|
||||
|
||||
- strong part: raw bundle/frame decode
|
||||
- diagnostic part: `typeWord -> bundle slot`
|
||||
|
||||
It also exports candidate late active-header override blobs to cache so the Ghidra-backed `DAT_800758d8` header-only lane can be inspected per run without pretending that binding is already solved.
|
||||
|
||||
The newer conclusion from `LSET1/L0` label failures is narrower than the earlier wording: if one type repeatedly paints a coherent room footprint with obviously wrong art, the exporter is probably visualizing valid world-object seed placement while still missing the separate static-world layer and the downstream executable bind/state path that chooses the final drawable resource.
|
||||
|
||||
Viewer-derived sidecars and donor mappings are no longer acceptable here because they blur exactly the binding problem the exporter is meant to isolate.
|
||||
|
||||
## Module Plan
|
||||
|
||||
### `src/wdl.js`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- read header words
|
||||
- compute post-audio start
|
||||
- derive regions from absolute boundary values
|
||||
- expose region buffers and summary metadata
|
||||
|
||||
Reason to isolate it:
|
||||
|
||||
- the carve is likely to change as more loader details land
|
||||
- record extraction should not depend on header internals
|
||||
|
||||
### `src/bundles.js`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- scan the graphics bank for plausible kind-4/kind-5 bundles
|
||||
- parse bundle headers and frame entries
|
||||
- decode frame bytes
|
||||
- emit grayscale PNG-ready RGBA buffers
|
||||
|
||||
When the standalone scan yields zero bundles for a map, `src/export-map.js` may hydrate bundle offsets and frame geometry from `out/psx_wdl_disc/.../summary.json` and continue decoding the actual frame bytes from the raw WDL.
|
||||
|
||||
Reason to isolate it:
|
||||
|
||||
- this code is reusable even if the map schema changes
|
||||
- it is the strongest raw-file-backed part of the exporter
|
||||
|
||||
### `src/export-map.js`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- choose the record source
|
||||
- choose diagnostic art binding
|
||||
- normalize screen bounds
|
||||
- write cache metadata and composed outputs
|
||||
|
||||
This file holds the intentionally weak parts of `v0` so they remain easy to replace.
|
||||
|
||||
### `src/render.js`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- sprite compositing
|
||||
- sort order approximation
|
||||
- PNG encoding
|
||||
- neutral opaque background for evaluation-friendly probe output
|
||||
|
||||
## Data Contracts
|
||||
|
||||
### Record
|
||||
|
||||
```json
|
||||
{
|
||||
"index": 0,
|
||||
"source": "region00",
|
||||
"typeWord": 74,
|
||||
"xWord": 5635,
|
||||
"yWord": 3815,
|
||||
"zWord": 0,
|
||||
"selectorWord": 1,
|
||||
"laneWord": 32,
|
||||
"screenX": -1820,
|
||||
"screenY": -4725
|
||||
}
|
||||
```
|
||||
|
||||
### Bundle
|
||||
|
||||
```json
|
||||
{
|
||||
"offsetInRegion": 58808,
|
||||
"absoluteOffset": 534068,
|
||||
"kind": 5,
|
||||
"mode": 2,
|
||||
"paletteIndex": 12,
|
||||
"frameCount": 3,
|
||||
"dataOffset": 112,
|
||||
"frameTableOffset": 52
|
||||
}
|
||||
```
|
||||
|
||||
### Scene Item
|
||||
|
||||
```json
|
||||
{
|
||||
"recordIndex": 0,
|
||||
"bundleSlot": 74,
|
||||
"bundleAbsoluteOffset": 954728,
|
||||
"frameIndex": 1,
|
||||
"screenX": -1820,
|
||||
"screenY": -4725,
|
||||
"drawX": -1879,
|
||||
"drawY": -4815,
|
||||
"width": 96,
|
||||
"height": 91,
|
||||
"originX": 59,
|
||||
"originY": 90
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
`v0` validation should answer four questions only:
|
||||
|
||||
1. Did the raw WDL parse into the documented regions?
|
||||
2. Did the graphics-bank scanner recover plausible bundles with decoded frames?
|
||||
3. Did the constructor-placement extractor recover plausible section-0 rows from the loader-sized section view?
|
||||
4. Did the compositor produce a non-empty PNG with recognizable art silhouettes on a neutral background?
|
||||
|
||||
This is enough for the first pass.
|
||||
|
||||
## Risks
|
||||
|
||||
### Binding risk
|
||||
|
||||
The diagnostic bundle binding is the weakest part of the pipeline.
|
||||
|
||||
Expected failure modes:
|
||||
|
||||
- correct placement with wrong art family
|
||||
- repeated art across several type families
|
||||
- frame clamping where selector words exceed available bundle frames
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep the chosen bundle slot, frame clamp count, and bundle-repeat metrics in output metadata
|
||||
|
||||
### Schema risk
|
||||
|
||||
The `region00` record extractor uses a plausibility scan instead of a final loader schema.
|
||||
|
||||
Expected failure modes:
|
||||
|
||||
- false positives in some maps
|
||||
- missing records when the preamble differs
|
||||
|
||||
Mitigation:
|
||||
|
||||
- preserve `recordStartOffset`
|
||||
- make `region01` fallback selectable from CLI
|
||||
|
||||
### Palette risk
|
||||
|
||||
Grayscale is intentionally not faithful to the executable color path.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- keep the grayscale rule explicit
|
||||
- do not mix partial CLUT heuristics into `v0`
|
||||
|
||||
## Immediate Follow-Up Options
|
||||
|
||||
After `v0` works, the next pass should choose one of these:
|
||||
|
||||
1. Replace provisional art binding with a loader-backed type/resource lookup.
|
||||
2. Parse the late `DAT_800758d8` bank directly from the large late graphics area instead of relying on slot order.
|
||||
3. Add executable-backed CLUT reconstruction once the palette path is pinned tightly enough.
|
||||
4. Recover stage-1 graph ordering when sprite placement is stable enough to make sort differences meaningful.
|
||||
256
psx-map-exporter/docs/spec.md
Normal file
256
psx-map-exporter/docs/spec.md
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
# PSX Map Exporter Spec
|
||||
|
||||
## Goal
|
||||
|
||||
`psx-map-exporter` is a standalone Node.js probe for Crusader PSX map extraction.
|
||||
|
||||
It exists to prove a fresh end-to-end path from raw `LSET*.WDL` input to:
|
||||
|
||||
- extracted intermediate sprite assets under `.cache`
|
||||
- a rendered map PNG under `.output`
|
||||
|
||||
This project does not reuse `Crusader-Map-Viewer` code, scene caches, donor mappings, or sidecar summaries as binding inputs. It only consumes raw PSX assets plus the documented executable-backed findings from `docs/psx` and the live Ghidra session.
|
||||
|
||||
## Scope
|
||||
|
||||
Version `v0` is intentionally narrow.
|
||||
|
||||
It will:
|
||||
|
||||
- read one PSX `LSET*.WDL` file
|
||||
- parse the documented `0x38`-byte top-level header
|
||||
- carve the post-audio map/art regions from header-derived boundaries
|
||||
- parse the loader-sized post-audio sections as a second, higher-value view of the file layout
|
||||
- extract the dense constructor-placement family from `post_audio_section_00`
|
||||
- keep the smaller root-dispatch family available as a comparison probe
|
||||
- render a layered authored probe that can combine constructor placements with the smaller root-dispatch lane
|
||||
- scan `post_audio_region_04` for type-4/type-5 sprite bundles
|
||||
- decode bundle frames directly from the raw WDL
|
||||
- write extracted frame PNGs to `.cache`
|
||||
- compose a probe map PNG to `.output`
|
||||
|
||||
It will not claim full runtime parity yet.
|
||||
|
||||
Known non-goals for `v0`:
|
||||
|
||||
- exact `DAT_800758d8/d0/cc/d4` parity
|
||||
- exact CLUT reproduction
|
||||
- full stage-1 dependency-graph ordering
|
||||
- exact type-to-resource binding for unresolved families
|
||||
- full `post_audio_region_01` / `post_audio_region_02` semantic decode
|
||||
|
||||
## Evidence Constraints
|
||||
|
||||
The implementation is grounded in these current facts from the docs and Ghidra:
|
||||
|
||||
- `LSET*.WDL` uses a fixed `0x38`-byte top-level header.
|
||||
- The second dword is the audio/SPU blob size.
|
||||
- The old region-only carve is not sufficient on its own for visible-object recovery; loader-sized `post_audio_section_00` contains both the small root-dispatch rows and the dense constructor-placement rows.
|
||||
- The file contains a post-audio area with four high-confidence absolute boundaries that split:
|
||||
- `post_audio_region_00`
|
||||
- `post_audio_region_01`
|
||||
- `post_audio_region_02`
|
||||
- `post_audio_region_03`
|
||||
- `post_audio_region_04`
|
||||
- The small count-prefixed section-0 root-dispatch rows are real, but they are not the whole map object set.
|
||||
- The dense constructor-placement records recovered from loader-sized `post_audio_section_00` are currently the best standalone live-object seed source, not a proven final visible-map layer.
|
||||
- Current strongest standalone layout read: the constructor-placement lane is a count-prefixed `12`-byte substream inside the loader-sized section-0 span rather than a whole-section `24`-byte row grid. For `LSET1/L0.WDL`, the best current candidate has a section-relative header at `0x38`, a record start at `0x3c`, and a reported count of `1182` records.
|
||||
- The constructor-placement stream can extend slightly past the nominal `post_audio_section_00` slice, so standalone parsing must follow the detected stream count from the section-0 base instead of truncating strictly at the section object boundary.
|
||||
- `post_audio_region_04` is the strongest current graphics bank candidate.
|
||||
- The direct `typeWord -> bundle slot` scan-order binding is disproven as a final art rule and is retained only as a diagnostic bundle-family probe.
|
||||
- The real art/template lane is `DAT_800758d8`, but the executable now shows two distinct late art feeds per WDL pass rather than one monolithic bank:
|
||||
- an earlier art-install blob that builds resources and temporarily mirrors them into `DAT_800758d8`
|
||||
- a later `8`-byte header-only override blob that restores raw active-header pointers into `DAT_800758d8`
|
||||
- The later header-only override is the safer standalone parser target: constructors branch on first dword `0x58` and then reuse `DAT_800758c8[type]`, so the final post-load `DAT_800758d8` state is a raw-header lane, not a permanently built-resource lane.
|
||||
- Type-4/type-5 drawable bundles expose width, height, palette mode/index, frame count, frame table offset, and data offset in the raw bundle header.
|
||||
- Bundle frame entries use a `20`-byte row with size, relative data offset, width, height, origin x/y, and flags.
|
||||
- `sprite_rle_decode_rows` uses row-local control bytes:
|
||||
- positive: repeat next byte N times
|
||||
- negative: copy next `abs(N)` literal bytes
|
||||
- zero: end row
|
||||
- The executable projection basis is:
|
||||
|
||||
$$
|
||||
screen_x = y - x
|
||||
$$
|
||||
|
||||
$$
|
||||
screen_y = 2z - \frac{x + y}{2}
|
||||
$$
|
||||
|
||||
## Input Model
|
||||
|
||||
The exporter accepts either:
|
||||
|
||||
- a direct `--wdl` path
|
||||
- or a `--source` path relative to a PSX disc root
|
||||
|
||||
Default disc root for local workspace runs:
|
||||
|
||||
- `d:/Ghidra/Crusader-Map-Viewer/map_renderer/STATIC_PSX`
|
||||
|
||||
Expected source examples:
|
||||
|
||||
- `LSET1/L0.WDL`
|
||||
- `LSET4/L37.WDL`
|
||||
|
||||
## Output Layout
|
||||
|
||||
### `.cache`
|
||||
|
||||
Per-run cache path:
|
||||
|
||||
- `.cache/<map-stem>/`
|
||||
|
||||
Contents:
|
||||
|
||||
- `wdl-summary.json`
|
||||
- `records.json`
|
||||
- `bundles.json`
|
||||
- `frame-manifest.json`
|
||||
- `active-header-overrides.json`
|
||||
- `sprites/<bundle-offset>/frame_<n>.png`
|
||||
|
||||
The cache is disposable. It exists to preserve intermediate evidence and make re-runs inspectable.
|
||||
|
||||
`records.json` now also records constructor-stream detection metadata when available: stream header offset, record start offset, reported count, and the initial structured-prefix run.
|
||||
|
||||
The cache also records candidate late `DAT_800758d8` header-only override blobs as a standalone diagnostic. Those candidates are not used as final art binding yet.
|
||||
|
||||
`wdl-summary.json` now also emits `sceneInterpretation`, which is an explicit warning-bearing classification of what the current export most likely represents. For constructor-placement exports this should currently read as a constructor-fed live-object seed lane rather than a final visible-world reconstruction.
|
||||
|
||||
### `.output`
|
||||
|
||||
Per-run final outputs:
|
||||
|
||||
- `.output/<map-stem>.png`
|
||||
- `.output/<map-stem>.json`
|
||||
- `.output/<map-stem>_<layer>.png` for each rendered authored layer when layered mode is active
|
||||
|
||||
The JSON stores the final probe scene manifest used to draw the PNG.
|
||||
|
||||
The `.output` folder is reset at the start of each export so evaluation only sees artifacts from the current run.
|
||||
|
||||
The `.output/<map-stem>.json` manifest inherits `sceneInterpretation` from `wdl-summary.json` so consumers do not need to infer that warning from prose docs alone.
|
||||
|
||||
## Record Extraction Rules
|
||||
|
||||
`v0` now uses the loader-sized `post_audio_section_00` extraction paths as the primary scene source.
|
||||
|
||||
Current interpretation constraint:
|
||||
|
||||
- `section0_constructor_placements` should currently be treated as constructor-fed world-object seed records.
|
||||
- They preserve meaningful layout and projection structure, but current evidence does not support treating them as the complete visible map or static architecture layer.
|
||||
- If a render shows coherent room layout with globally wrong or repeated art, the exporter is currently visualizing one runtime object lane without the downstream per-type bind/state path and without the separate static-world substrate.
|
||||
|
||||
Record extraction rule:
|
||||
|
||||
- `auto` / `combined` / `layered` mode merges both authored section-0 families into one layered probe:
|
||||
- constructor placements provide the dense live-object seed lane
|
||||
- root-dispatch rows provide the smaller comparison and auxiliary authored lane
|
||||
- `constructors` / `region01` mode first searches the section-0 span for a count-prefixed `12`-byte constructor stream and, when found, treats each record as six little-endian `u16` words:
|
||||
- `typeWord`
|
||||
- `xWord`
|
||||
- `yWord`
|
||||
- `zWord`
|
||||
- `selectorWord`
|
||||
- `laneWord`
|
||||
- If a count-prefixed constructor stream is not found, the exporter falls back to the older whole-section `24`-byte paired-record scan as a compatibility probe.
|
||||
- `roots` / `region00` mode keeps the small count-prefixed root-dispatch probe for comparison and negative-evidence checks
|
||||
|
||||
Plausibility filter:
|
||||
|
||||
- `typeWord` in a conservative visible-family range
|
||||
- not all coordinate words are zero
|
||||
- `laneWord` is non-zero and within the current conservative control-word range
|
||||
|
||||
This is explicitly a probe schema, not a final loader-faithful schema.
|
||||
|
||||
Current negative result:
|
||||
|
||||
- Correcting the constructor stream start/count for `LSET1/L0.WDL` only changes the standalone constructor probe slightly (`1130 -> 1135` records, `1090 -> 1095` rendered items) and does not materially change the repeated wrong-art output. Current evidence therefore points to unresolved art/runtime binding as the primary blocker, not a missed constructor-tail decode.
|
||||
|
||||
## Art Binding Rule
|
||||
|
||||
`v0` uses one explicit diagnostic binding rule:
|
||||
|
||||
- `typeWord -> bundle slot index`
|
||||
|
||||
That means the sorted bundle list from `post_audio_region_04` is indexed directly by `typeWord` when the slot exists.
|
||||
|
||||
This rule is explicitly not claimed as final executable truth. Current docs and Ghidra evidence show the final art path goes through the late `DAT_800758d8` art bank plus downstream state-script/runtime selection. The slot rule remains useful only as a clean standalone negative-evidence probe.
|
||||
|
||||
For the generic family band now dominating `LSET1/L0` failures (`0x003e`, `0x0042`, `0x0044`, `0x0045`, `0x004f`, `0x0059`, `0x005b`), repeated wrong art is now understood as both a binding failure and a semantic-layer failure: the exporter is currently visualizing constructor-fed runtime object seeds as though they were the final visible world.
|
||||
|
||||
The chosen bundle and clamped frame index, plus binding-diversity metrics, are preserved in output metadata so failures stay auditable.
|
||||
|
||||
When debug labels are enabled for a map render, labels now identify unique rendered resources rather than per-instance placements. The stable label key is currently `bundle offset + clamped frame + resolved palette`. Validation atlas sheets still use progressive cell indices.
|
||||
|
||||
## Rendering Rule
|
||||
|
||||
For each record:
|
||||
|
||||
- compute `screenX` and `screenY` from the documented projection basis
|
||||
- select frame index from `selectorWord`, clamped to available frames
|
||||
- place sprite top-left at:
|
||||
- `screenX - originX`
|
||||
- `screenY - originY`
|
||||
|
||||
Current draw order is conservative:
|
||||
|
||||
- main-visible before special-visible
|
||||
- then ascending `screenY`
|
||||
- then ascending `screenX`
|
||||
|
||||
This is a probe approximation. The later graph-based stage-1 ordering still belongs to a future pass.
|
||||
|
||||
The rendered PNG uses a neutral opaque background by default so probe silhouettes are legible without relying on transparency.
|
||||
|
||||
## Color Rule
|
||||
|
||||
`v0` emits grayscale art from raw pixel indices.
|
||||
|
||||
Reason:
|
||||
|
||||
- bundle frame decode is already well constrained
|
||||
- full CLUT parity is not
|
||||
- grayscale preserves shape/variant evidence without pretending the palette problem is solved
|
||||
|
||||
Transparent index `0` stays transparent.
|
||||
|
||||
## CLI
|
||||
|
||||
Primary command:
|
||||
|
||||
```powershell
|
||||
node src/cli.js --source LSET1/L0.WDL
|
||||
```
|
||||
|
||||
Supported options:
|
||||
|
||||
- `--source <relative-path>`
|
||||
- `--wdl <absolute-or-relative-file>`
|
||||
- `--disc-root <path>`
|
||||
- `--map-source <auto|combined|layered|constructors|roots|region01|region00>`
|
||||
- `--out-name <stem>`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
`v0` is successful if it can:
|
||||
|
||||
- parse a raw `LSET*.WDL`
|
||||
- recover the loader-sized section view alongside the region carve
|
||||
- scan bundles directly from `post_audio_region_04`
|
||||
- decode at least one frame from raw data
|
||||
- extract a stable constructor-placement record set from `post_audio_section_00`
|
||||
- write extracted sprite PNGs into `.cache`
|
||||
- write a readable diagnostic probe PNG into `.output`
|
||||
|
||||
## Planned Follow-Ups
|
||||
|
||||
- replace diagnostic slot binding with a direct parser for the late header-only `DAT_800758d8` override stream and bundle match path
|
||||
- recover the exact raw on-disk encoding of the earlier built-resource art-install blob so the two late art feeds are modeled separately instead of flattened into one guessed bank
|
||||
- identify and parse the separate static-world or subordinate level substrate that complements the constructor-fed live-object lane, instead of treating section-0 constructor placements as the whole map
|
||||
- add palette/CLUT reconstruction
|
||||
- add stage-1 graph ordering recovery
|
||||
- compare the probe scene against fixed live samples such as `map 104` without reintroducing viewer-side donor assumptions
|
||||
22
psx-map-exporter/package-lock.json
generated
Normal file
22
psx-map-exporter/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "psx-map-exporter",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psx-map-exporter",
|
||||
"dependencies": {
|
||||
"pngjs": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
psx-map-exporter/package.json
Normal file
11
psx-map-exporter/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "psx-map-exporter",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"export": "node src/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"pngjs": "^7.0.0"
|
||||
}
|
||||
}
|
||||
475
psx-map-exporter/src/bundles.js
Normal file
475
psx-map-exporter/src/bundles.js
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import { PNG } from 'pngjs';
|
||||
|
||||
function readU32LE(buffer, offset) {
|
||||
return buffer.readUInt32LE(offset);
|
||||
}
|
||||
|
||||
function readU16LE(buffer, offset) {
|
||||
return buffer.readUInt16LE(offset);
|
||||
}
|
||||
|
||||
function rowByteWidth(width, mode) {
|
||||
return mode === 2 ? Math.ceil(width / 2) : width;
|
||||
}
|
||||
|
||||
function psx555ToRgba(color) {
|
||||
const red = (color & 0x1f) * 255 / 31;
|
||||
const green = ((color >> 5) & 0x1f) * 255 / 31;
|
||||
const blue = ((color >> 10) & 0x1f) * 255 / 31;
|
||||
const alpha = (color & 0x7fff) === 0 ? 0 : 255;
|
||||
return {
|
||||
red: Math.round(red),
|
||||
green: Math.round(green),
|
||||
blue: Math.round(blue),
|
||||
alpha,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractPaletteSets(buffer, headerWords) {
|
||||
if (!Array.isArray(headerWords) || headerWords.length < 4) {
|
||||
return { palettes16: [], palettes256: [] };
|
||||
}
|
||||
|
||||
const paletteOffset = headerWords[2];
|
||||
const paletteSize = headerWords[3];
|
||||
if (paletteSize !== 0x1000 || paletteOffset < 0 || paletteOffset + paletteSize > buffer.length) {
|
||||
return { palettes16: [], palettes256: [] };
|
||||
}
|
||||
|
||||
const blob = buffer.subarray(paletteOffset, paletteOffset + paletteSize);
|
||||
const palettes16 = [];
|
||||
const palettes256 = [];
|
||||
|
||||
for (let offset = 0; offset + 0x20 <= blob.length; offset += 0x20) {
|
||||
const palette = [];
|
||||
for (let entry = 0; entry < 16; entry += 1) {
|
||||
palette.push(readU16LE(blob, offset + entry * 2));
|
||||
}
|
||||
palettes16.push(palette);
|
||||
}
|
||||
|
||||
for (let offset = 0; offset + 0x200 <= blob.length; offset += 0x200) {
|
||||
const palette = [];
|
||||
for (let entry = 0; entry < 256; entry += 1) {
|
||||
palette.push(readU16LE(blob, offset + entry * 2));
|
||||
}
|
||||
palettes256.push(palette);
|
||||
}
|
||||
|
||||
return { palettes16, palettes256 };
|
||||
}
|
||||
|
||||
export function buildMode1RuntimePaletteForIndex(palettes16, startIndex = 0) {
|
||||
if (!Array.isArray(palettes16) || palettes16.length < 16) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(startIndex) || startIndex < 0 || startIndex + 16 > palettes16.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette = [];
|
||||
for (let paletteIndex = startIndex; paletteIndex < startIndex + 16; paletteIndex += 1) {
|
||||
const clut = palettes16[paletteIndex];
|
||||
if (!Array.isArray(clut) || clut.length < 16) {
|
||||
return null;
|
||||
}
|
||||
palette.push(...clut.slice(0, 16));
|
||||
}
|
||||
return palette.length === 256 ? palette : null;
|
||||
}
|
||||
|
||||
export function buildMode1RuntimePalette(palettes16) {
|
||||
return buildMode1RuntimePaletteForIndex(palettes16, 0);
|
||||
}
|
||||
|
||||
export function extractMode1PaletteFromGpuRamDump(buffer, row = 0xf0, startX = 0) {
|
||||
const vramWidthWords = 1024;
|
||||
const vramHeight = 512;
|
||||
const expectedSize = vramWidthWords * vramHeight * 2;
|
||||
|
||||
if (!buffer || buffer.length < expectedSize) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(row) || row < 0 || row >= vramHeight) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(startX) || startX < 0 || startX + 256 > vramWidthWords) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette = [];
|
||||
const rowStart = (row * vramWidthWords * 2) + (startX * 2);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
palette.push(readU16LE(buffer, rowStart + index * 2));
|
||||
}
|
||||
|
||||
return palette;
|
||||
}
|
||||
|
||||
export function buildMode1PaletteBank(palettes16) {
|
||||
if (!Array.isArray(palettes16) || palettes16.length < 16) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paletteBank = [];
|
||||
for (let startIndex = 0; startIndex < palettes16.length; startIndex += 1) {
|
||||
const palette = buildMode1RuntimePaletteForIndex(palettes16, startIndex);
|
||||
if (palette?.length === 256) {
|
||||
paletteBank[startIndex] = palette;
|
||||
}
|
||||
}
|
||||
return paletteBank;
|
||||
}
|
||||
|
||||
export function choosePalette(palettes16, frames, mode) {
|
||||
if (mode !== 2 || !Array.isArray(palettes16) || palettes16.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usedIndices = new Set();
|
||||
for (const frame of frames) {
|
||||
const rawPixels = frame.rawPixels;
|
||||
if (!rawPixels) {
|
||||
continue;
|
||||
}
|
||||
for (const value of rawPixels) {
|
||||
usedIndices.add(value & 0x0f);
|
||||
usedIndices.add((value >> 4) & 0x0f);
|
||||
}
|
||||
}
|
||||
|
||||
usedIndices.delete(0);
|
||||
if (usedIndices.size === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let bestIndex = null;
|
||||
let bestScore = -1;
|
||||
for (let paletteIndex = 0; paletteIndex < palettes16.length; paletteIndex += 1) {
|
||||
const palette = palettes16[paletteIndex];
|
||||
const distinct = new Set();
|
||||
for (const index of usedIndices) {
|
||||
distinct.add((palette[index] ?? 0) & 0x7fff);
|
||||
}
|
||||
|
||||
let channelSpread = 0;
|
||||
let nonZeroCount = 0;
|
||||
for (const value of distinct) {
|
||||
if (value === 0) {
|
||||
continue;
|
||||
}
|
||||
nonZeroCount += 1;
|
||||
const rgba = psx555ToRgba(value);
|
||||
channelSpread += rgba.red + rgba.green + rgba.blue;
|
||||
}
|
||||
|
||||
if (nonZeroCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const score = nonZeroCount * 100000 + channelSpread;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = paletteIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
function isValidBundleHeader(buffer, offset) {
|
||||
if (offset + 0x34 > buffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const kind = readU32LE(buffer, offset + 0x00);
|
||||
const width = readU32LE(buffer, offset + 0x08);
|
||||
const height = readU32LE(buffer, offset + 0x0c);
|
||||
const mode = readU32LE(buffer, offset + 0x10);
|
||||
const dataOffset = readU32LE(buffer, offset + 0x1c);
|
||||
const frameCount = readU32LE(buffer, offset + 0x20);
|
||||
const frameTableOffset = readU32LE(buffer, offset + 0x24);
|
||||
|
||||
if (kind !== 4 && kind !== 5) {
|
||||
return false;
|
||||
}
|
||||
if (width === 0 || height === 0 || width > 512 || height > 512) {
|
||||
return false;
|
||||
}
|
||||
if (mode !== 1 && mode !== 2) {
|
||||
return false;
|
||||
}
|
||||
if (frameCount === 0 || frameCount > 256) {
|
||||
return false;
|
||||
}
|
||||
if (offset + dataOffset > buffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recordTableSize = frameCount * 20;
|
||||
if (dataOffset < 0x34 + recordTableSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frameTableOffset !== 0x34) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function scanSpriteBundles(region) {
|
||||
const bundles = [];
|
||||
const seenRanges = [];
|
||||
|
||||
for (let offset = 0; offset + 0x34 <= region.buffer.length; offset += 4) {
|
||||
if (!isValidBundleHeader(region.buffer, offset)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenRanges.some(([start, end]) => offset >= start && offset < end)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = readU32LE(region.buffer, offset + 0x00);
|
||||
const width = readU32LE(region.buffer, offset + 0x08);
|
||||
const height = readU32LE(region.buffer, offset + 0x0c);
|
||||
const mode = readU32LE(region.buffer, offset + 0x10);
|
||||
const paletteIndex = readU32LE(region.buffer, offset + 0x14);
|
||||
const dataOffset = readU32LE(region.buffer, offset + 0x1c);
|
||||
const frameCount = readU32LE(region.buffer, offset + 0x20);
|
||||
const frameTableOffset = 0x34;
|
||||
|
||||
if (paletteIndex > 127) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const frames = [];
|
||||
let valid = true;
|
||||
|
||||
for (let index = 0; index < frameCount; index += 1) {
|
||||
const entryOffset = offset + frameTableOffset + (index * 20);
|
||||
const flags = readU32LE(region.buffer, entryOffset + 0x00);
|
||||
const relativeDataOffset = readU32LE(region.buffer, entryOffset + 0x08);
|
||||
const frameWidth = readU16LE(region.buffer, entryOffset + 0x0c);
|
||||
const frameHeight = readU16LE(region.buffer, entryOffset + 0x0e);
|
||||
const originX = readU16LE(region.buffer, entryOffset + 0x10);
|
||||
const originY = readU16LE(region.buffer, entryOffset + 0x12);
|
||||
|
||||
const dataStart = offset + dataOffset + (((flags & 1) === 1) ? relativeDataOffset * 4 : relativeDataOffset);
|
||||
const rawSize = rowByteWidth(frameWidth, mode) * frameHeight;
|
||||
if (
|
||||
frameWidth === 0 ||
|
||||
frameHeight === 0 ||
|
||||
frameWidth > 512 ||
|
||||
frameHeight > 512 ||
|
||||
dataStart >= region.buffer.length ||
|
||||
(((flags & 1) === 0) && (dataStart + rawSize > region.buffer.length))
|
||||
) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
|
||||
let consumed;
|
||||
if ((flags & 1) === 1) {
|
||||
const decoded = decodeRleRows(region.buffer, dataStart, frameWidth, frameHeight, mode);
|
||||
if (!decoded) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
consumed = decoded.consumed;
|
||||
} else {
|
||||
consumed = rawSize;
|
||||
}
|
||||
|
||||
frames.push({
|
||||
index,
|
||||
consumed,
|
||||
relativeDataOffset,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
originX,
|
||||
originY,
|
||||
flags,
|
||||
dataStart,
|
||||
absoluteDataStart: region.offset + dataStart,
|
||||
});
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenRanges.push([offset, offset + dataOffset]);
|
||||
|
||||
bundles.push({
|
||||
slot: bundles.length,
|
||||
offsetInRegion: offset,
|
||||
absoluteOffset: region.offset + offset,
|
||||
kind,
|
||||
width,
|
||||
height,
|
||||
mode,
|
||||
paletteIndex,
|
||||
dataOffset,
|
||||
frameCount,
|
||||
frameTableOffset,
|
||||
frames,
|
||||
});
|
||||
}
|
||||
|
||||
return bundles;
|
||||
}
|
||||
|
||||
function decodeRleRows(buffer, start, width, height, mode) {
|
||||
const expectedSize = rowByteWidth(width, mode) * height;
|
||||
const output = [];
|
||||
let cursor = start;
|
||||
let rows = 0;
|
||||
|
||||
while (rows < height) {
|
||||
if (cursor >= buffer.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const controlByte = buffer[cursor];
|
||||
cursor += 1;
|
||||
const signedControl = controlByte < 0x80 ? controlByte : controlByte - 0x100;
|
||||
|
||||
if (signedControl === 0) {
|
||||
rows += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signedControl < 0) {
|
||||
const count = controlByte & 0x7f;
|
||||
if (cursor + count > buffer.length) {
|
||||
return null;
|
||||
}
|
||||
output.push(...buffer.subarray(cursor, cursor + count));
|
||||
cursor += count;
|
||||
} else {
|
||||
if (cursor >= buffer.length) {
|
||||
return null;
|
||||
}
|
||||
const value = buffer[cursor];
|
||||
cursor += 1;
|
||||
for (let repeat = 0; repeat < signedControl; repeat += 1) {
|
||||
output.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (output.length > expectedSize) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (output.length !== expectedSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
rawPixels: Buffer.from(output),
|
||||
consumed: cursor - start,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeIndexedPixels(rawPixels, width, height, mode) {
|
||||
if (mode === 2) {
|
||||
const indexed = Buffer.alloc(width * height, 0);
|
||||
let source = 0;
|
||||
let target = 0;
|
||||
const rowBytes = rowByteWidth(width, mode);
|
||||
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const rowEnd = Math.min(source + rowBytes, rawPixels.length);
|
||||
while (source < rowEnd && target < indexed.length) {
|
||||
const value = rawPixels[source];
|
||||
source += 1;
|
||||
|
||||
indexed[target] = value & 0x0f;
|
||||
target += 1;
|
||||
if (target < indexed.length) {
|
||||
indexed[target] = (value >> 4) & 0x0f;
|
||||
target += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return indexed;
|
||||
}
|
||||
|
||||
return Buffer.from(rawPixels.subarray(0, width * height));
|
||||
}
|
||||
|
||||
function indexedToGrayscaleRgba(pixels, mode) {
|
||||
const rgba = Buffer.alloc(pixels.length * 4, 0);
|
||||
for (let index = 0; index < pixels.length; index += 1) {
|
||||
const sourceValue = pixels[index];
|
||||
const value = mode === 2 ? sourceValue * 17 : sourceValue;
|
||||
const out = index * 4;
|
||||
rgba[out + 0] = value;
|
||||
rgba[out + 1] = value;
|
||||
rgba[out + 2] = value;
|
||||
rgba[out + 3] = value === 0 ? 0 : 255;
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
function indexedToColorRgba(pixels, palette) {
|
||||
const rgba = Buffer.alloc(pixels.length * 4, 0);
|
||||
for (let index = 0; index < pixels.length; index += 1) {
|
||||
const paletteIndex = pixels[index];
|
||||
const color = palette[paletteIndex] ?? 0;
|
||||
const converted = psx555ToRgba(color);
|
||||
const out = index * 4;
|
||||
rgba[out + 0] = converted.red;
|
||||
rgba[out + 1] = converted.green;
|
||||
rgba[out + 2] = converted.blue;
|
||||
rgba[out + 3] = paletteIndex === 0 ? 0 : converted.alpha;
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
export function decodeBundleFrame(region, bundle, frameIndex, palette = null) {
|
||||
const frame = bundle.frames[Math.max(0, Math.min(frameIndex, bundle.frames.length - 1))];
|
||||
const rawSize = rowByteWidth(frame.width, bundle.mode) * frame.height;
|
||||
|
||||
let rawPixels;
|
||||
let consumed;
|
||||
if ((frame.flags & 1) === 1) {
|
||||
const decoded = decodeRleRows(region.buffer, frame.dataStart, frame.width, frame.height, bundle.mode);
|
||||
if (!decoded) {
|
||||
throw new Error(`Failed to decode RLE frame at 0x${frame.absoluteDataStart.toString(16)}`);
|
||||
}
|
||||
rawPixels = decoded.rawPixels;
|
||||
consumed = decoded.consumed;
|
||||
} else {
|
||||
if (frame.dataStart + rawSize > region.buffer.length) {
|
||||
throw new Error(`Frame overruns bundle region at 0x${frame.absoluteDataStart.toString(16)}`);
|
||||
}
|
||||
rawPixels = region.buffer.subarray(frame.dataStart, frame.dataStart + rawSize);
|
||||
consumed = rawSize;
|
||||
}
|
||||
|
||||
const indexedPixels = decodeIndexedPixels(rawPixels, frame.width, frame.height, bundle.mode);
|
||||
const rgba = Array.isArray(palette)
|
||||
? indexedToColorRgba(indexedPixels, palette)
|
||||
: indexedToGrayscaleRgba(indexedPixels, bundle.mode);
|
||||
|
||||
return {
|
||||
...frame,
|
||||
consumed,
|
||||
rawPixels: Buffer.from(rawPixels),
|
||||
indexedPixels,
|
||||
requestedFrameIndex: frameIndex,
|
||||
clampedFrameIndex: frame.index,
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodePng(rgba, width, height) {
|
||||
const png = new PNG({ width, height });
|
||||
png.data = Buffer.from(rgba);
|
||||
return PNG.sync.write(png);
|
||||
}
|
||||
160
psx-map-exporter/src/cli.js
Normal file
160
psx-map-exporter/src/cli.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { exportMap } from './export-map.js';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const options = {
|
||||
discRoot: path.resolve(
|
||||
moduleDir,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'Crusader-Map-Viewer',
|
||||
'map_renderer',
|
||||
'STATIC_PSX'
|
||||
),
|
||||
gpuRamDump: path.resolve(
|
||||
moduleDir,
|
||||
'..',
|
||||
'..',
|
||||
'binary',
|
||||
'Crusader - No Remorse (USA) GPU RAM 2.bin'
|
||||
),
|
||||
mapSource: 'auto',
|
||||
sceneScope: 'probe',
|
||||
validationBundles: [],
|
||||
};
|
||||
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
if (arg === '--source') {
|
||||
options.source = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--wdl') {
|
||||
options.wdl = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--disc-root') {
|
||||
options.discRoot = path.resolve(next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--map-source') {
|
||||
options.mapSource = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--scene-scope') {
|
||||
options.sceneScope = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--gpu-ram-dump') {
|
||||
options.gpuRamDump = path.resolve(next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--validation-bundles') {
|
||||
options.validationBundles = String(next)
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--out-name') {
|
||||
options.outName = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--debug-labels') {
|
||||
options.debugLabels = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log([
|
||||
'Usage: node src/cli.js (--source LSET1/L0.WDL | --wdl <file>) [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --source <relative path> WDL path relative to the PSX disc root',
|
||||
' --wdl <file> Direct WDL path',
|
||||
' --disc-root <path> PSX asset root, defaults to STATIC_PSX in the sibling workspace',
|
||||
' --scene-scope <probe|full> Probe is supported; full is intentionally disabled until raw floor and full-map decode is recovered',
|
||||
' --gpu-ram-dump <path> PSX GPU RAM dump used for live mode-1 palette extraction',
|
||||
' --validation-bundles <csv> Comma-separated bundle absolute offsets (hex or decimal) for bundle palette-sweep validation sheets',
|
||||
' --map-source <auto|combined|layered|constructors|roots|region01|region00>',
|
||||
' --out-name <stem> Override the output stem',
|
||||
' --debug-labels Write an additional labeled scene PNG for item identification',
|
||||
'',
|
||||
'Notes:',
|
||||
' auto now prefers a layered probe that combines constructor placements with root-dispatch rows.',
|
||||
' combined/layered explicitly renders both authored section-0 lanes together.',
|
||||
' roots/region00 keeps the smaller section-0 root-dispatch probe for comparison.',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv);
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.source && !options.wdl) {
|
||||
printHelp();
|
||||
throw new Error('Either --source or --wdl is required.');
|
||||
}
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const wdlPath = options.wdl
|
||||
? path.resolve(options.wdl)
|
||||
: path.resolve(options.discRoot, options.source);
|
||||
|
||||
const result = await exportMap({
|
||||
projectRoot,
|
||||
wdlPath,
|
||||
sourceRelPath: options.source,
|
||||
mapSource: options.mapSource,
|
||||
sceneScope: options.sceneScope,
|
||||
gpuRamDumpPath: options.gpuRamDump,
|
||||
validationBundles: options.validationBundles,
|
||||
outName: options.outName,
|
||||
debugLabels: Boolean(options.debugLabels),
|
||||
});
|
||||
|
||||
console.log(JSON.stringify({
|
||||
sourceFile: wdlPath,
|
||||
mapStem: result.mapStem,
|
||||
recordCount: result.summary.recordCount,
|
||||
renderableItemCount: result.summary.renderableItemCount,
|
||||
bundleCount: result.summary.bundleCount,
|
||||
outputPngPath: result.outputPngPath,
|
||||
debugPngPath: result.debugPngPath,
|
||||
outputJsonPath: result.outputJsonPath,
|
||||
validationOutputs: result.validationOutputs,
|
||||
region02Example: result.region02Example,
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
1073
psx-map-exporter/src/export-map.js
Normal file
1073
psx-map-exporter/src/export-map.js
Normal file
File diff suppressed because it is too large
Load diff
169
psx-map-exporter/src/render.js
Normal file
169
psx-map-exporter/src/render.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { encodePng } from './bundles.js';
|
||||
|
||||
const DEFAULT_BACKGROUND = { red: 18, green: 18, blue: 18, alpha: 255 };
|
||||
|
||||
const GLYPHS = {
|
||||
'0': ['111', '101', '101', '101', '111'],
|
||||
'1': ['010', '110', '010', '010', '111'],
|
||||
'2': ['111', '001', '111', '100', '111'],
|
||||
'3': ['111', '001', '111', '001', '111'],
|
||||
'4': ['101', '101', '111', '001', '001'],
|
||||
'5': ['111', '100', '111', '001', '111'],
|
||||
'6': ['111', '100', '111', '101', '111'],
|
||||
'7': ['111', '001', '001', '001', '001'],
|
||||
'8': ['111', '101', '111', '101', '111'],
|
||||
'9': ['111', '101', '111', '001', '111'],
|
||||
};
|
||||
|
||||
function clearCanvas(width, height, background = null) {
|
||||
const canvas = Buffer.alloc(width * height * 4, 0);
|
||||
if (!background) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
fillRect(
|
||||
canvas,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
background.red ?? 0,
|
||||
background.green ?? 0,
|
||||
background.blue ?? 0,
|
||||
background.alpha ?? 255,
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function fillRect(canvas, canvasWidth, canvasHeight, x, y, width, height, red, green, blue, alpha) {
|
||||
const startX = Math.max(0, x);
|
||||
const startY = Math.max(0, y);
|
||||
const endX = Math.min(canvasWidth, x + width);
|
||||
const endY = Math.min(canvasHeight, y + height);
|
||||
|
||||
for (let drawY = startY; drawY < endY; drawY += 1) {
|
||||
for (let drawX = startX; drawX < endX; drawX += 1) {
|
||||
const target = ((drawY * canvasWidth) + drawX) * 4;
|
||||
canvas[target + 0] = red;
|
||||
canvas[target + 1] = green;
|
||||
canvas[target + 2] = blue;
|
||||
canvas[target + 3] = alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawGlyph(canvas, canvasWidth, canvasHeight, glyph, x, y, red, green, blue, alpha) {
|
||||
const rows = GLYPHS[glyph];
|
||||
if (!rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
||||
const row = rows[rowIndex];
|
||||
for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
|
||||
if (row[columnIndex] !== '1') {
|
||||
continue;
|
||||
}
|
||||
fillRect(canvas, canvasWidth, canvasHeight, x + columnIndex, y + rowIndex, 1, 1, red, green, blue, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawLabel(canvas, canvasWidth, canvasHeight, text, x, y) {
|
||||
const label = String(text);
|
||||
const glyphWidth = 3;
|
||||
const glyphHeight = 5;
|
||||
const spacing = 1;
|
||||
const boxWidth = (label.length * (glyphWidth + spacing)) - spacing + 2;
|
||||
const boxHeight = glyphHeight + 2;
|
||||
fillRect(canvas, canvasWidth, canvasHeight, x, y, boxWidth, boxHeight, 0, 0, 0, 220);
|
||||
|
||||
let cursorX = x + 1;
|
||||
for (const glyph of label) {
|
||||
drawGlyph(canvas, canvasWidth, canvasHeight, glyph, cursorX, y + 1, 255, 255, 0, 255);
|
||||
cursorX += glyphWidth + spacing;
|
||||
}
|
||||
}
|
||||
|
||||
function blitRgba(canvas, canvasWidth, canvasHeight, sprite, dstX, dstY, flipped = false) {
|
||||
for (let y = 0; y < sprite.height; y += 1) {
|
||||
const canvasY = dstY + y;
|
||||
if (canvasY < 0 || canvasY >= canvasHeight) {
|
||||
continue;
|
||||
}
|
||||
for (let x = 0; x < sprite.width; x += 1) {
|
||||
const canvasX = dstX + x;
|
||||
if (canvasX < 0 || canvasX >= canvasWidth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceX = flipped ? (sprite.width - 1 - x) : x;
|
||||
const source = ((y * sprite.width) + sourceX) * 4;
|
||||
const alpha = sprite.rgba[source + 3];
|
||||
if (alpha === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = ((canvasY * canvasWidth) + canvasX) * 4;
|
||||
canvas[target + 0] = sprite.rgba[source + 0];
|
||||
canvas[target + 1] = sprite.rgba[source + 1];
|
||||
canvas[target + 2] = sprite.rgba[source + 2];
|
||||
canvas[target + 3] = alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMap(items, options = {}) {
|
||||
if (items.length === 0) {
|
||||
throw new Error('No renderable scene items were produced.');
|
||||
}
|
||||
|
||||
const bounds = items.reduce(
|
||||
(state, item) => ({
|
||||
minX: Math.min(state.minX, item.drawX),
|
||||
minY: Math.min(state.minY, item.drawY),
|
||||
maxX: Math.max(state.maxX, item.drawX + item.width),
|
||||
maxY: Math.max(state.maxY, item.drawY + item.height),
|
||||
}),
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
|
||||
);
|
||||
|
||||
const padding = 16;
|
||||
const width = Math.max(1, (bounds.maxX - bounds.minX) + (padding * 2));
|
||||
const height = Math.max(1, (bounds.maxY - bounds.minY) + (padding * 2));
|
||||
const canvas = clearCanvas(width, height, options.background ?? DEFAULT_BACKGROUND);
|
||||
|
||||
for (const item of items) {
|
||||
blitRgba(
|
||||
canvas,
|
||||
width,
|
||||
height,
|
||||
item.sprite,
|
||||
item.drawX - bounds.minX + padding,
|
||||
item.drawY - bounds.minY + padding,
|
||||
Boolean(item.flipped)
|
||||
);
|
||||
}
|
||||
|
||||
if (options.drawLabels) {
|
||||
for (const item of items) {
|
||||
drawLabel(
|
||||
canvas,
|
||||
width,
|
||||
height,
|
||||
item.labelId ?? item.id,
|
||||
item.drawX - bounds.minX + padding,
|
||||
item.drawY - bounds.minY + padding
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
bounds,
|
||||
png: encodePng(canvas, width, height),
|
||||
};
|
||||
}
|
||||
449
psx-map-exporter/src/wdl.js
Normal file
449
psx-map-exporter/src/wdl.js
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
import path from 'node:path';
|
||||
|
||||
function readU32LE(buffer, offset) {
|
||||
return buffer.readUInt32LE(offset);
|
||||
}
|
||||
|
||||
function readU16LE(buffer, offset) {
|
||||
return buffer.readUInt16LE(offset);
|
||||
}
|
||||
|
||||
const ALLOWED_LANE_WORDS = new Set([0x20, 0x22, 0x30]);
|
||||
const PSX_SCREEN_SCALE = 2;
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values)].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
export function parseLsetWdl(buffer, filePath) {
|
||||
if (buffer.length < 0x34) {
|
||||
throw new Error(`File too small for LSET header: ${filePath}`);
|
||||
}
|
||||
|
||||
const headerSize = readU32LE(buffer, 0);
|
||||
if (headerSize < 0x34 || headerSize % 4 !== 0 || headerSize > buffer.length) {
|
||||
throw new Error(`Unexpected header size 0x${headerSize.toString(16)} in ${filePath}`);
|
||||
}
|
||||
|
||||
const headerWords = [];
|
||||
for (let offset = 0; offset < headerSize; offset += 4) {
|
||||
headerWords.push(readU32LE(buffer, offset));
|
||||
}
|
||||
|
||||
const audioSize = readU32LE(buffer, 4);
|
||||
const postAudioStart = headerSize + audioSize;
|
||||
const sectionSizes = [];
|
||||
for (let offset = 0x08; offset < 0x38 && offset + 4 <= buffer.length; offset += 4) {
|
||||
sectionSizes.push(readU32LE(buffer, offset));
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
let sectionCursor = postAudioStart;
|
||||
for (let index = 0; index < sectionSizes.length; index += 1) {
|
||||
const size = sectionSizes[index];
|
||||
if (size <= 0 || sectionCursor + size > buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
sections.push({
|
||||
name: `post_audio_section_${String(index).padStart(2, '0')}`,
|
||||
offset: sectionCursor,
|
||||
size,
|
||||
buffer: buffer.subarray(sectionCursor, sectionCursor + size),
|
||||
});
|
||||
sectionCursor += size;
|
||||
}
|
||||
|
||||
const boundaryCandidates = uniqueSorted(
|
||||
headerWords
|
||||
.slice(2)
|
||||
.filter((value) => value > postAudioStart && value < buffer.length)
|
||||
);
|
||||
|
||||
if (boundaryCandidates.length < 4) {
|
||||
throw new Error(
|
||||
`Expected at least 4 post-audio boundaries, found ${boundaryCandidates.length} in ${filePath}`
|
||||
);
|
||||
}
|
||||
|
||||
const selectedBoundaries = boundaryCandidates.slice(0, 4);
|
||||
const regions = [];
|
||||
const regionStarts = [postAudioStart, ...selectedBoundaries];
|
||||
const regionEnds = [...selectedBoundaries, buffer.length];
|
||||
|
||||
regions.push({
|
||||
name: 'audio_or_spu_blob',
|
||||
offset: headerSize,
|
||||
size: audioSize,
|
||||
buffer: buffer.subarray(headerSize, postAudioStart),
|
||||
});
|
||||
|
||||
for (let index = 0; index < regionStarts.length; index += 1) {
|
||||
const offset = regionStarts[index];
|
||||
const end = regionEnds[index];
|
||||
regions.push({
|
||||
name: `post_audio_region_${String(index).padStart(2, '0')}`,
|
||||
offset,
|
||||
size: end - offset,
|
||||
buffer: buffer.subarray(offset, end),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
fileName: path.basename(filePath),
|
||||
buffer,
|
||||
headerSize,
|
||||
audioSize,
|
||||
postAudioStart,
|
||||
headerWords,
|
||||
sectionSizes,
|
||||
sections,
|
||||
boundaryCandidates,
|
||||
regions,
|
||||
};
|
||||
}
|
||||
|
||||
function isPlausibleRecord(words) {
|
||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||
if (typeWord < 0x20 || typeWord > 0x1ff) {
|
||||
return false;
|
||||
}
|
||||
if ((xWord | yWord | zWord) === 0) {
|
||||
return false;
|
||||
}
|
||||
if (laneWord === 0 || laneWord > 0x1fff) {
|
||||
return false;
|
||||
}
|
||||
if (selectorWord > 0x03ff) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isStructuredCandidate(words) {
|
||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||
if (typeWord >= 0x200) {
|
||||
return false;
|
||||
}
|
||||
if (xWord === 0 && yWord === 0) {
|
||||
return false;
|
||||
}
|
||||
if (xWord >= 0x4000 || yWord >= 0x4000) {
|
||||
return false;
|
||||
}
|
||||
if (zWord > 0x20 || selectorWord > 0x04) {
|
||||
return false;
|
||||
}
|
||||
if (!ALLOWED_LANE_WORDS.has(laneWord)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecord(words, source, offset, rawWords = words) {
|
||||
if (!isPlausibleRecord(words)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||
const screenX = (yWord - xWord) * PSX_SCREEN_SCALE;
|
||||
const screenY = ((2 * zWord) - Math.floor((xWord + yWord) / 2)) * PSX_SCREEN_SCALE;
|
||||
|
||||
return {
|
||||
index: -1,
|
||||
source,
|
||||
offset,
|
||||
words,
|
||||
rawWords,
|
||||
typeWord,
|
||||
xWord,
|
||||
yWord,
|
||||
zWord,
|
||||
selectorWord,
|
||||
laneWord,
|
||||
screenX,
|
||||
screenY,
|
||||
sourceFamily: null,
|
||||
sourceRole: null,
|
||||
recordSide: null,
|
||||
rowIndex: -1,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRecord(buffer, offset, source) {
|
||||
const words = [];
|
||||
for (let cursor = 0; cursor < 12; cursor += 2) {
|
||||
words.push(readU16LE(buffer, offset + cursor));
|
||||
}
|
||||
|
||||
return buildRecord(words, source, offset, words);
|
||||
}
|
||||
|
||||
function makeAsciiPreview(buffer, length = 64) {
|
||||
const slice = buffer.subarray(0, Math.min(length, buffer.length));
|
||||
let text = '';
|
||||
for (const value of slice) {
|
||||
text += value >= 0x20 && value <= 0x7e ? String.fromCharCode(value) : '.';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function scanOffsetTableCandidates(buffer, maxBase = 0x200) {
|
||||
const candidates = [];
|
||||
const limit = Math.min(maxBase, Math.max(0, buffer.length - 8));
|
||||
|
||||
for (let base = 0; base <= limit; base += 2) {
|
||||
const count = readU16LE(buffer, base);
|
||||
if (count <= 0 || count >= 0x200) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tableEnd = base + 2 + count * 2;
|
||||
if (tableEnd > buffer.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let previous = -1;
|
||||
let monotonic = true;
|
||||
const firstOffsets = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = readU16LE(buffer, base + 2 + index * 2);
|
||||
if (index < 8) {
|
||||
firstOffsets.push(offset);
|
||||
}
|
||||
if (offset < previous || offset >= buffer.length) {
|
||||
monotonic = false;
|
||||
break;
|
||||
}
|
||||
previous = offset;
|
||||
}
|
||||
|
||||
if (monotonic) {
|
||||
candidates.push({ base, count, firstOffsets });
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function scanPlausible12ByteRecordStarts(buffer, maxBase = 0x200) {
|
||||
const starts = [];
|
||||
const limit = Math.min(maxBase, Math.max(0, buffer.length - 12));
|
||||
|
||||
for (let base = 0; base <= limit; base += 2) {
|
||||
const words = [];
|
||||
for (let cursor = 0; cursor < 12; cursor += 2) {
|
||||
words.push(readU16LE(buffer, base + cursor));
|
||||
}
|
||||
if (isPlausibleRecord(words)) {
|
||||
starts.push({ base, words });
|
||||
}
|
||||
}
|
||||
|
||||
return starts;
|
||||
}
|
||||
|
||||
function buildPreviewRows(buffer, rowWordWidth = 8, rowCount = 24) {
|
||||
const rows = [];
|
||||
const maxRows = Math.min(rowCount, Math.floor(buffer.length / (rowWordWidth * 2)));
|
||||
|
||||
for (let rowIndex = 0; rowIndex < maxRows; rowIndex += 1) {
|
||||
const offset = rowIndex * rowWordWidth * 2;
|
||||
const words = [];
|
||||
for (let wordIndex = 0; wordIndex < rowWordWidth; wordIndex += 1) {
|
||||
words.push(readU16LE(buffer, offset + wordIndex * 2));
|
||||
}
|
||||
|
||||
const bytes = buffer.subarray(offset, offset + rowWordWidth * 2);
|
||||
rows.push({
|
||||
rowIndex,
|
||||
offset,
|
||||
words,
|
||||
ascii: makeAsciiPreview(bytes, bytes.length),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function summarizeRegion02(region) {
|
||||
const firstU32 = [];
|
||||
const firstU16 = [];
|
||||
|
||||
for (let offset = 0; offset + 4 <= region.buffer.length && firstU32.length < 8; offset += 4) {
|
||||
firstU32.push(readU32LE(region.buffer, offset));
|
||||
}
|
||||
for (let offset = 0; offset + 2 <= region.buffer.length && firstU16.length < 16; offset += 2) {
|
||||
firstU16.push(readU16LE(region.buffer, offset));
|
||||
}
|
||||
|
||||
const offsetTableCandidates = scanOffsetTableCandidates(region.buffer);
|
||||
const plausible12ByteRecordStarts = scanPlausible12ByteRecordStarts(region.buffer);
|
||||
|
||||
return {
|
||||
offset: region.offset,
|
||||
size: region.size,
|
||||
firstU32,
|
||||
firstU16,
|
||||
asciiPreview: makeAsciiPreview(region.buffer, 96),
|
||||
previewRows: buildPreviewRows(region.buffer),
|
||||
offsetTableCandidates: offsetTableCandidates.slice(0, 16),
|
||||
plausible12ByteRecordStarts: plausible12ByteRecordStarts.slice(0, 16),
|
||||
note: offsetTableCandidates.length === 0 && plausible12ByteRecordStarts.length === 0
|
||||
? 'Leading region-02 bytes do not look like a count-prefixed offset table or direct 12-byte placement rows.'
|
||||
: 'Region-02 exposes candidate structure and should be correlated against live loader-installed subordinate slices.',
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRegion00Records(region) {
|
||||
const rowCount = region.buffer.length >= 4 ? readU32LE(region.buffer, 0) : 0;
|
||||
const records = [];
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
|
||||
const rowBase = 4 + rowIndex * 24;
|
||||
if (rowBase + 24 > region.buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const rowWords = [];
|
||||
for (let wordIndex = 0; wordIndex < 12; wordIndex += 1) {
|
||||
rowWords.push(readU16LE(region.buffer, rowBase + wordIndex * 2));
|
||||
}
|
||||
|
||||
const leftRawWords = rowWords.slice(0, 6);
|
||||
const rightRawWords = rowWords.slice(6, 12);
|
||||
const leftWords = [rowWords[4], rowWords[5], rowWords[0], rowWords[1], rowWords[2], rowWords[3]];
|
||||
const rightWords = [rowWords[10], rowWords[11], rowWords[6], rowWords[7], rowWords[8], rowWords[9]];
|
||||
|
||||
for (const [recordSide, wordSet, rawWordSet, sourceByteOffset] of [
|
||||
['left', leftWords, leftRawWords, 0],
|
||||
['right', rightWords, rightRawWords, 12],
|
||||
]) {
|
||||
const record = buildRecord(wordSet, 'region00', rowBase + sourceByteOffset, rawWordSet);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
|
||||
record.sourceFamily = 'section0_dispatch_roots';
|
||||
record.sourceRole = 'root-dispatch';
|
||||
record.rowIndex = rowIndex;
|
||||
record.recordSide = recordSide;
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
records.forEach((record, index) => {
|
||||
record.index = index;
|
||||
record.absoluteOffset = region.offset + record.offset;
|
||||
});
|
||||
|
||||
return {
|
||||
source: 'region00',
|
||||
recordStartOffset: 4,
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
function detectStructured12ByteStream(buffer) {
|
||||
let bestCandidate = null;
|
||||
|
||||
for (let headerOffset = 0; headerOffset + 16 <= buffer.length; headerOffset += 4) {
|
||||
const count = readU32LE(buffer, headerOffset);
|
||||
const recordStartOffset = headerOffset + 4;
|
||||
const maxPossibleCount = Math.floor((buffer.length - recordStartOffset) / 12);
|
||||
|
||||
if (count === 0 || count > maxPossibleCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let prefixStructuredCount = 0;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const recordOffset = recordStartOffset + index * 12;
|
||||
const words = [];
|
||||
for (let cursor = 0; cursor < 12; cursor += 2) {
|
||||
words.push(readU16LE(buffer, recordOffset + cursor));
|
||||
}
|
||||
if (!isStructuredCandidate(words)) {
|
||||
break;
|
||||
}
|
||||
prefixStructuredCount += 1;
|
||||
}
|
||||
|
||||
if (prefixStructuredCount < 16) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!bestCandidate ||
|
||||
prefixStructuredCount > bestCandidate.prefixStructuredCount ||
|
||||
(prefixStructuredCount === bestCandidate.prefixStructuredCount && headerOffset < bestCandidate.headerOffset)
|
||||
) {
|
||||
bestCandidate = {
|
||||
headerOffset,
|
||||
recordStartOffset,
|
||||
count,
|
||||
prefixStructuredCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return bestCandidate;
|
||||
}
|
||||
|
||||
export function parseRegion01Records(region) {
|
||||
const records = [];
|
||||
const stream = detectStructured12ByteStream(region.buffer);
|
||||
|
||||
if (stream) {
|
||||
for (let index = 0; index < stream.count; index += 1) {
|
||||
const recordOffset = stream.recordStartOffset + index * 12;
|
||||
if (recordOffset + 12 > region.buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const record = decodeRecord(region.buffer, recordOffset, 'region01');
|
||||
if (!record || !isStructuredCandidate(record.words)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
record.sourceFamily = 'section0_constructor_placements';
|
||||
record.sourceRole = 'constructor-placement';
|
||||
record.rowIndex = index;
|
||||
record.recordSide = null;
|
||||
records.push(record);
|
||||
}
|
||||
} else {
|
||||
for (let rowOffset = 0; rowOffset + 24 <= region.buffer.length; rowOffset += 24) {
|
||||
const left = decodeRecord(region.buffer, rowOffset, 'region01-left');
|
||||
const right = decodeRecord(region.buffer, rowOffset + 12, 'region01-right');
|
||||
if (left && isStructuredCandidate(left.words)) {
|
||||
left.sourceFamily = 'section0_constructor_placements';
|
||||
left.sourceRole = 'constructor-placement';
|
||||
left.rowIndex = Math.floor(rowOffset / 24);
|
||||
left.recordSide = 'left';
|
||||
records.push(left);
|
||||
}
|
||||
if (right && isStructuredCandidate(right.words)) {
|
||||
right.sourceFamily = 'section0_constructor_placements';
|
||||
right.sourceRole = 'constructor-placement';
|
||||
right.rowIndex = Math.floor(rowOffset / 24);
|
||||
right.recordSide = 'right';
|
||||
records.push(right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records.forEach((record, index) => {
|
||||
record.index = index;
|
||||
record.absoluteOffset = region.offset + record.offset;
|
||||
});
|
||||
|
||||
return {
|
||||
source: 'region01',
|
||||
recordStartOffset: stream?.recordStartOffset ?? 0,
|
||||
streamHeaderOffset: stream?.headerOffset ?? null,
|
||||
streamRecordCount: stream?.count ?? null,
|
||||
streamStructuredPrefixCount: stream?.prefixStructuredCount ?? null,
|
||||
records,
|
||||
};
|
||||
}
|
||||
91
psx-map-exporter/tmp_inspect_region00.mjs
Normal file
91
psx-map-exporter/tmp_inspect_region00.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseLsetWdl } from './src/wdl.js';
|
||||
|
||||
const wdlPath = path.resolve('..', '..', 'Crusader-Map-Viewer', 'map_renderer', 'STATIC_PSX', 'LSET1', 'L0.WDL');
|
||||
const buffer = fs.readFileSync(wdlPath);
|
||||
const wdl = parseLsetWdl(buffer, wdlPath);
|
||||
const section = wdl.sections.find((entry) => entry.name === 'post_audio_section_00');
|
||||
const region = wdl.regions.find((entry) => entry.name === 'post_audio_region_00');
|
||||
|
||||
function readWords(sourceBuffer, offset, wordCount = 6) {
|
||||
return Array.from({ length: wordCount }, (_, index) => sourceBuffer.readUInt16LE(offset + index * 2));
|
||||
}
|
||||
|
||||
function isStructuredCandidate(words) {
|
||||
const [typeWord, xWord, yWord, zWord, selectorWord, laneWord] = words;
|
||||
if (typeWord >= 0x200) {
|
||||
return false;
|
||||
}
|
||||
if (xWord === 0 && yWord === 0) {
|
||||
return false;
|
||||
}
|
||||
if (xWord >= 0x4000 || yWord >= 0x4000) {
|
||||
return false;
|
||||
}
|
||||
if (zWord > 0x20 || selectorWord > 0x04) {
|
||||
return false;
|
||||
}
|
||||
return laneWord === 0x20 || laneWord === 0x22 || laneWord === 0x30;
|
||||
}
|
||||
|
||||
function inspectCountPrefixed12ByteStreams(source) {
|
||||
const hits = [];
|
||||
|
||||
for (let offset = 0; offset + 4 + 12 <= source.buffer.length; offset += 4) {
|
||||
const count = source.buffer.readUInt32LE(offset);
|
||||
if (count === 0 || count > 0x2000) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let good = 0;
|
||||
const preview = [];
|
||||
for (let index = 0; index < count && offset + 4 + (index + 1) * 12 <= source.buffer.length; index += 1) {
|
||||
const recordOffset = offset + 4 + index * 12;
|
||||
const words = readWords(source.buffer, recordOffset);
|
||||
const structured = isStructuredCandidate(words);
|
||||
if (index < 6) {
|
||||
preview.push({ index, recordOffset, words, structured });
|
||||
}
|
||||
if (!structured) {
|
||||
break;
|
||||
}
|
||||
good += 1;
|
||||
}
|
||||
|
||||
if (good >= 16) {
|
||||
hits.push({
|
||||
offset,
|
||||
absoluteOffset: source.offset + offset,
|
||||
count,
|
||||
good,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
const sectionHits = inspectCountPrefixed12ByteStreams(section);
|
||||
const regionHits = inspectCountPrefixed12ByteStreams(region);
|
||||
const preview = [];
|
||||
for (let offset = 0; offset < 0x90; offset += 12) {
|
||||
const words = readWords(section.buffer, offset);
|
||||
preview.push({
|
||||
offset,
|
||||
absoluteOffset: section.offset + offset,
|
||||
words,
|
||||
structured: isStructuredCandidate(words),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
sectionOffset: section.offset,
|
||||
sectionSize: section.size,
|
||||
regionOffset: region.offset,
|
||||
regionSize: region.size,
|
||||
sectionHits,
|
||||
regionHits,
|
||||
preview,
|
||||
}, null, 2));
|
||||
Loading…
Add table
Add a link
Reference in a new issue