Compare commits

..

4 Commits

Author SHA1 Message Date
sylph-decoder
08416dbd0b fix(viewer,cli,export): clear the remaining 30 clippy lints
`cargo clippy --workspace -- -D warnings` now exits 0. `cargo test
--workspace` still reports 207 passed, 0 failed, 14 ignored across 30
suites — identical to runs 203 and 204, so none of this changed behaviour.

The workspace total was 73, not the 48 run 204 reported. `-D warnings`
turns a lint into a hard compile error, so `sylpheed-formats` failing
stopped its dependents from ever being built: `sylpheed-viewer` (14) and
`sylpheed-cli` (11) had never been linted by anyone. Clearing formats in
bc79817 is what made them visible.

  formats  43 -> 0   (bc79817)
  viewer   14 -> 0
  cli      11 -> 0
  export    5 -> 0

Collision surface, measured rather than assumed. Every viewer file
carrying a lint is byte-identical on both `auto/frame-blend-draw-path`
(495 commits) and `auto/port-p6-audio` (366). All eleven cli sites fall
outside every hunk either branch touches. 68 of the 73 sites could not
collide with anything.

The five that can are all in `sylpheed-export`, and three of those are
real:

  main.rs:278   `&out` -> `out`, inside frame-blend's hunk -278,12
  main.rs:318   `&out` -> `out`, inside port-p6-audio's hunk -303,44
  audio.rs:113  an added `#[allow]` in a file frame-blend DELETES

Each is one line. Resolving the first two means taking the branch's
version and re-applying a borrow removal; the third resolves to the
deletion. Flagged here so neither branch owner meets them cold.

Judgement calls, all stated at the site rather than suppressed globally:

* Three `too_many_arguments` in the viewer are false positives.
  `draw_viewer_ui`, `poll_loader_channel` and `apply_pak` are Bevy
  systems — every parameter is a `Res`/`ResMut`/`EventWriter` the
  scheduler injects, so the count is the framework's dependency list and
  cannot be reduced without a `SystemParam` struct.
* `cmd_screen_render` (cli, 8/7) is a plain function, so that one is real
  if mild; its arguments are the subcommand's flags.
* Two `dead_code` fields in export are serde schema fields. They model
  what the on-disc JSON accepts; deleting them would quietly change that.
* `iso_loader.rs` gains a `FrameRx` alias for the ffmpeg frame channel,
  which is what "very complex type" was asking for.

A site-local `#[allow]` with a reason is a decision recorded where it
applies: one lint, one function, and any new violation elsewhere still
fails the build. That is not the shape PROTOCOL.md forbids.

Closes #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 17:47:50 +02:00
sylph-decoder
bc79817488 fix(formats): clear all 43 clippy lints in sylpheed-formats
Run 204 gave this repository its first clippy measurement — 48 errors, 43
of them in `sylpheed-formats`. This clears that 43 to zero under the exact
invocation CI runs, `cargo clippy -p sylpheed-formats -- -D warnings`.

Why this crate first, and why it is safe to touch:

Every one of the 43 sites was checked against the line ranges that
`auto/frame-blend-draw-path` (495 commits) and `auto/port-p6-audio` (366)
actually modify. None of them overlap. Eleven of the fifteen affected
files are byte-identical on both branches, including `mesh.rs` and
`texture.rs`, which carry 28 of the hits between them. The three sites in
`audio.rs`, `ui_layout.rs` and `slb.rs` that live in files those branches
do change fall outside every modified hunk. The collision argument that
defers #12 does not transfer here; it was tested rather than assumed.

It also unblocks a measurement. `-D warnings` turns a lint in this crate
into a hard compile error, so its dependents never build — `sylpheed-cli`
and `sylpheed-viewer` have never been linted at all, and viewer is the
largest crate in the workspace. Both depend only on `sylpheed-formats`
(`sylpheed-export` pins it from a git tag instead), so this commit is what
makes their real counts knowable.

  38  applied by `cargo clippy --fix` — chunks_exact_to_as_chunks,
      manual_div_ceil / is_multiple_of / range_contains, unnecessary_map_or,
      needless_borrow, let_and_return, dead_code, unused_mut/variables.
      Purely local expression rewrites: 38 insertions, 39 deletions.
   2  by hand: a doc continuation that markdown was parsing as a list, and
      `d / frame` behind a `frame > 0` guard becoming `checked_div`.
   3  `#[allow(clippy::too_many_arguments)]` with a stated reason.

On those three allows: 8 parameters against a threshold of 7, in the mesh
anchor path. The real fix is a shared params struct across
`anchor_pool_mesh`, `validate_block` and `validate_block_report` — the
latter two take the same eight arguments and one delegates to the other —
which is a change to the decoder's signatures and belongs to whoever owns
that path, not to a CI-lint pass.

This is not the shape PROTOCOL.md forbids. `continue-on-error` suppresses
everything, present and future, at the job level, and cannot tell "not
yet" from "no longer". A site-local `#[allow]` with a reason is a decision
recorded where it applies: one lint, one function, and any new violation
anywhere else still fails the build.

`sylpheed-export`'s remaining 5 are deliberately untouched — three of them
sit inside hunks both long-lived branches modify, and that crate blocks
nothing. Left for #13.

Refs #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 16:59:57 +02:00
sylph-decoder
d8dcc1fc29 docs: record the third softening, which was authored dirty
The two instances already in "Checks that were kind once" were correct
when written and decayed. The third was wrong on its first commit, and it
arrived by a different route: the check and the tree's failure to pass it
land in the same change, so the softening writes itself.

Concretely — the Clippy step had never run (no component in the
toolchain), and the tree is not clippy-clean, so fixing the step and
turning it red are the same commit. The first draft paired the fix with
`continue-on-error: true` and a comment promising removal once the debt
was paid: an expiry date nobody set, in the shape #12's closing line had
already ruled out for rustfmt. Reverted on reading it.

Adds the distinction, a table separating decay from dirty authorship, and
an earlier tell than the mechanical test:

    If you are writing the softening in the same commit as the check,
    the thing you want is an issue, not a flag.

The mechanical test is unchanged and still correct; this only catches the
same failure sooner, at the keyboard rather than at review.

Refs #12, #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 15:55:04 +02:00
sylph-decoder
4ac5c9f419 ci: install the clippy component the Clippy step needs
`dtolnay/rust-toolchain@stable` installs a minimal profile. The `native`
job named no components, so every run that reached the Clippy step died
on

    error: 'cargo-clippy' is not installed for the toolchain
           'stable-aarch64-unknown-linux-gnu'

before clippy read a line of source. That is not a lint result; the step
had never run. The `fmt` job below always named `components: rustfmt`
correctly — this one never did.

Two lines of behaviour change. The rest is the comment explaining why the
step is left gating on `-D warnings` rather than softened: the workspace
is not clippy-clean (run 203's build alone emits ~13 rustc warnings that
`-D warnings` promotes to errors), and `continue-on-error` cannot tell
"debt not yet paid" from "debt paid". That debt is scoped in #13, the way
the rustfmt debt is in #12.

Run 203 is what made this visible. With the aarch64 fix in 64bb7da the
native job got all the way through:

    cargo check  --workspace   ok  10m01s
    cargo build  --workspace   ok  19m04s
    cargo test   --workspace   ok  16m22s   214 passed, 0 failed
    cargo clippy --workspace   toolchain error

Refs #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 15:39:44 +02:00
1188 changed files with 7379 additions and 242200 deletions

View File

@@ -1,29 +0,0 @@
# getrandom 0.3 refuses to build for wasm32-unknown-unknown unless it is told
# which backend to use. The target has no OS entropy source, so the crate will
# not guess: it wants the `wasm_js` backend named explicitly, AND the matching
# feature enabled on the crate itself. Its own error is unusually clear that one
# without the other is not enough:
#
# error: The wasm32-unknown-unknown targets are not supported by default; you
# may need to enable the "wasm_js" configuration flag. Note that enabling the
# `wasm_js` feature flag alone is insufficient.
#
# This is the cfg half. The feature half is a wasm32-only dependency in
# crates/sylpheed-viewer/Cargo.toml — that is the only crate reaching getrandom
# here, transitively through `ahash`.
#
# Scoped to the wasm target, so native builds are untouched. Note that a
# RUSTFLAGS environment variable, if one is ever set, replaces this rather than
# adding to it.
#
# The second cfg is bevy_egui's. Its web clipboard support calls web-sys APIs
# that are still gated behind an unstable flag, and it refuses to build without
# it rather than silently dropping the feature:
#
# error: bevy_egui uses unstable APIs to support clipboard on web.
#
[target.wasm32-unknown-unknown]
rustflags = [
'--cfg', 'getrandom_backend="wasm_js"',
'--cfg', 'web_sys_unstable_apis',
]

View File

@@ -31,24 +31,6 @@ env:
# So: one job, on the machine that exists, building for the machine that exists. # So: one job, on the machine that exists, building for the machine that exists.
# If a second architecture is ever wanted here it needs a second RUNNER, not a # If a second architecture is ever wanted here it needs a second RUNNER, not a
# second matrix row. # second matrix row.
#
# ── The toolchain is PINNED, in three places, deliberately ───────────────────
#
# All three jobs used `dtolnay/rust-toolchain@stable`, which resolves to whatever
# stable is on the day the job runs. A lint gate that floats is not a gate: the
# same tree goes green or red depending on the date, and this repo has already
# produced a disagreement between two people reading the same commit (#15).
# `collapsible_else_if` is the example — `warn` on 1.92.0, `allow`-by-default
# pedantic on 1.98.1, so a clean local run and a red CI run were both correct.
#
# `1.98.1` is the version run 206 resolved, and `docker/ci/Dockerfile` pins the
# same one, so `docker/ci/run cargo clippy …` on a desktop is a true stand-in for
# this workflow rather than an approximation of it.
#
# To bump: change all three `dtolnay/rust-toolchain@` refs here AND the `FROM
# rust:<version>-bookworm` in `docker/ci/Dockerfile` in one commit, so the two
# can never drift apart silently. A bump is a change to the gate and belongs in
# its own PR, where the new lints it turns on are the diff.
jobs: jobs:
# ── Native build, on the one runner there is ──────────────────────────────── # ── Native build, on the one runner there is ────────────────────────────────
@@ -60,15 +42,13 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Rust toolchain - name: Install Rust toolchain
# Pinned — see the toolchain note at the top of this file. # `stable` installs a MINIMAL profile: rustc, cargo, rust-std and no
#
# This action installs a MINIMAL profile: rustc, cargo, rust-std and no
# more. Components have to be named. Without this line the Clippy step # more. Components have to be named. Without this line the Clippy step
# below dies on "'cargo-clippy' is not installed for the toolchain # below dies on "'cargo-clippy' is not installed for the toolchain
# 'stable-aarch64-unknown-linux-gnu'" — which is not a lint result, it # 'stable-aarch64-unknown-linux-gnu'" — which is not a lint result, it
# is the step never having run. The `fmt` job below always got this # is the step never having run. The `fmt` job below always got this
# right; this one never did. # right; this one never did.
uses: dtolnay/rust-toolchain@1.98.1 uses: dtolnay/rust-toolchain@stable
with: with:
components: clippy components: clippy
@@ -97,17 +77,6 @@ jobs:
- name: Run tests - name: Run tests
run: cargo test --workspace run: cargo test --workspace
# The tally above cannot tell you what it verified. `cargo test` reports
# the same count whether the disc corpus was exercised or entirely
# absent -- a gated suite that skips still counts as passed, and the
# `ignored` column is a static count of `#[ignore]` attributes that cannot
# move at runtime. Issue #16. This prints what the run ACTUALLY had, from
# a file, because a passing test's stdout is captured and would be
# invisible in exactly this log.
- name: Report which corpora the tests actually had
if: always()
run: cat target/sylpheed-corpus-report.txt || echo "(no corpus report produced -- did corpus_report run?)"
# This step has never once executed on this codebase: the toolchain above # This step has never once executed on this codebase: the toolchain above
# shipped without the component, so every run died on "not installed" # shipped without the component, so every run died on "not installed"
# before clippy saw a line of source. Its result was never pass or fail, # before clippy saw a line of source. Its result was never pass or fail,
@@ -134,7 +103,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Rust toolchain + WASM target - name: Install Rust toolchain + WASM target
uses: dtolnay/rust-toolchain@1.98.1 uses: dtolnay/rust-toolchain@stable
with: with:
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
@@ -142,16 +111,7 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Install Trunk - name: Install Trunk
# v0.5.0 selects the download by PLATFORM ONLY and never consults the uses: jetli/trunk-action@v0.5.0
# architecture -- `case 'linux': arch = 'x86_64-unknown-linux-gnu'` --
# so on this aarch64 runner it fetches an x86_64 binary. v0.5.1 adds
# `process.arch` with 'x64' -> 'x86_64', 'arm64' -> 'aarch64' and
# core.setFailed otherwise, so a wrong arch now fails loudly instead of
# silently. It also moves the download host thedodd/trunk ->
# trunk-rs/trunk (trunk moved repositories; v0.5.0 still points at the
# old one), and swaps io.mv for io.cp, which is what avoids EXDEV on a
# self-hosted runner whose /tmp is a separate filesystem -- ours.
uses: jetli/trunk-action@v0.5.1
- name: Check WASM compile - name: Check WASM compile
run: > run: >
@@ -163,17 +123,11 @@ jobs:
- name: Build WASM release with Trunk - name: Build WASM release with Trunk
run: trunk build --release run: trunk build --release
# No artifact upload. actions/upload-artifact@v4 hard-refuses on Gitea -- - name: Upload WASM dist artifact
# Gitea presents as GHES and @actions/artifact v2+ aborts there uses: actions/upload-artifact@v4
# (go-gitea/gitea#31256, #36024). Nothing consumes `web-dist`: it had with:
# exactly one reference in this repository, the line that produced it, name: web-dist
# and there is no download-artifact and no second workflow. The job's path: dist/
# purpose -- proving the web build compiles -- is met by the step above.
# Add it back when something consumes the bundle, and decide then between
# actions/upload-artifact@v3 (the GHES guidance names v3.2.2 / the
# -node20 tag, so check the runner's node first) and the Gitea-specific
# christopherHX/gitea-upload-artifact@v4, which is a third-party
# dependency and therefore a decision, not a swap.
# ── Format check ───────────────────────────────────────────────────────────── # ── Format check ─────────────────────────────────────────────────────────────
fmt: fmt:
@@ -181,7 +135,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.98.1 - uses: dtolnay/rust-toolchain@stable
with: with:
components: rustfmt components: rustfmt
- run: cargo fmt --all -- --check - run: cargo fmt --all -- --check

7
.gitignore vendored
View File

@@ -18,13 +18,6 @@ Thumbs.db
# Local dev overrides # Local dev overrides
.env .env
# The static-analysis database `tools/zq.py` reads by default. A build artefact
# of several hundred MB (`sylph-xexdb dis ... --db sylpheed.db`), and zq.py
# now tells people to put it exactly here -- so it must never be committable.
# One `git add -A` would otherwise put it in public history for good.
/sylpheed.db
/sylpheed.db.wal
# Trunk build output # Trunk build output
dist/ dist/
__pycache__/ __pycache__/

795
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,6 @@ members = [
"crates/sylpheed-viewer", "crates/sylpheed-viewer",
"crates/sylpheed-cli", "crates/sylpheed-cli",
"crates/sylpheed-export", "crates/sylpheed-export",
"crates/sylpheed-xex",
"crates/sylpheed-ppc",
"crates/sylpheed-xexdb",
] ]
resolver = "2" resolver = "2"

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Fabian
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -194,36 +194,6 @@ enum ScreenCommands {
/// `--build`**, which is why it is a flag and not the default. /// `--build`**, which is why it is a flag and not the default.
#[arg(long)] #[arg(long)]
all: bool, all: bool,
/// Pose every element at this KEYFRAME TIME instead of at its resting
/// pose (60 units = 1 second). The resting pose is each element's last
/// *hold* keyframe, picked independently of every other element, so it is
/// not the screen at any one moment: it omits anything still moving (the
/// title's light sweeps hold off the right edge) and freezes a transient
/// at its PEAK (the title's five two-frame flashes burn forever).
/// ⚠️ This help used to end "Prefer `--settle`". That is WITHDRAWN and was
/// never measured: scored against a live capture of the JP title, settle
/// gives RMSE 40.210 and rest 41.690 — a margin of 1.48 against that
/// instrument's own noise floor of 1.2, which is NOT decisive. `--settle`
/// also has its own failure mode (25.5 % of elements are mid-ramp at their
/// screen's settle instant). Neither is established as better; pick by what
/// you are measuring. See `docs/re/structures/ui-resting-pose.md`.
#[arg(long, conflicts_with = "settle")]
at: Option<u32>,
/// Pose every element at the instant the screen is SETTLED, derived from
/// the disc: the midpoint of the longest interval containing no keyframe
/// of any element. Prints the window it used, whose width is how much the
/// midpoint is worth — a narrow one means the bundle never settles.
/// ⚠️ That is **38 % of the screen builds this command renders** (185 of
/// 491 carrying two or more keyframe times) and 39 % of the wider set
/// `--all` admits (862 of 2 211), mostly `loop*` fragments. This help used
/// to say "42 % of them" without saying of WHAT: 42 % was 731/1 758 over
/// composable bundles, computed before the keyframe record-layout fix,
/// which times a group's final pose and so admits ~450 bundles that
/// previously had only one timed keyframe. ⚠️ NOT established as better
/// than the resting pose — see the note on `--at`. See
/// `docs/re/structures/ui-settle-time.md`.
#[arg(long)]
settle: bool,
}, },
} }
@@ -335,7 +305,7 @@ async fn main() -> Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::from_default_env() tracing_subscriber::EnvFilter::from_default_env()
.add_directive("sylpheed=info".parse().unwrap()), .add_directive("sylpheed=info".parse().unwrap())
) )
.init(); .init();
@@ -343,33 +313,24 @@ async fn main() -> Result<()> {
match cli.command { match cli.command {
Commands::Extract { iso, output } => cmd_extract(&iso, &output).await, Commands::Extract { iso, output } => cmd_extract(&iso, &output).await,
Commands::List { iso, filter } => cmd_list(&iso, filter).await, Commands::List { iso, filter } => cmd_list(&iso, filter).await,
Commands::Sniff { dir, unknown_only } => cmd_sniff(&dir, unknown_only), Commands::Sniff { dir, unknown_only } => cmd_sniff(&dir, unknown_only),
Commands::Texture { cmd } => match cmd { Commands::Texture { cmd } => match cmd {
TextureCommands::Info { file } => cmd_texture_info(&file), TextureCommands::Info { file } => cmd_texture_info(&file),
TextureCommands::Export { file, output } => cmd_texture_export(&file, &output), TextureCommands::Export { file, output } => cmd_texture_export(&file, &output),
}, },
Commands::Mesh { cmd } => match cmd { Commands::Mesh { cmd } => match cmd {
MeshCommands::Info { file } => cmd_mesh_info(&file), MeshCommands::Info { file } => cmd_mesh_info(&file),
MeshCommands::Render { MeshCommands::Render { file, output, size, yaw, pitch, dist, row, only } => {
file, cmd_mesh_render(&file, &output, size, yaw, pitch, dist, row, only)
output, }
size,
yaw,
pitch,
dist,
row,
only,
} => cmd_mesh_render(&file, &output, size, yaw, pitch, dist, row, only),
}, },
Commands::Pak { cmd } => match cmd { Commands::Pak { cmd } => match cmd {
PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only), PakCommands::List { pak, idxd_only } => cmd_pak_list(&pak, idxd_only),
PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash), PakCommands::Dump { pak, hash } => cmd_pak_dump(&pak, &hash),
PakCommands::Textures { PakCommands::Textures { pak, output, verbose } => {
pak, cmd_pak_textures(&pak, &output, verbose)
output, }
verbose,
} => cmd_pak_textures(&pak, &output, verbose),
}, },
Commands::Audio { cmd } => match cmd { Commands::Audio { cmd } => match cmd {
AudioCommands::Info { file } => cmd_audio_info(&file), AudioCommands::Info { file } => cmd_audio_info(&file),
@@ -391,10 +352,8 @@ async fn main() -> Result<()> {
black, black,
all, all,
primitives, primitives,
at,
settle,
} => cmd_screen_render( } => cmd_screen_render(
&pak, &output, build, focus, animated, black, all, primitives, at, settle, &pak, &output, build, focus, animated, black, all, primitives,
), ),
}, },
Commands::Save { cmd } => match cmd { Commands::Save { cmd } => match cmd {
@@ -522,9 +481,7 @@ fn cmd_screen_info(pak: &Path, want: Option<usize>, geometry: bool, all: bool) -
"{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} {rest}", "{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} {rest}",
el.index, el.index,
el.name, el.name,
el.parent el.parent.map(|p| p.to_string()).unwrap_or_else(|| "-".into()),
.map(|p| p.to_string())
.unwrap_or_else(|| "-".into()),
format!("{:#x}", el.kind), format!("{:#x}", el.kind),
format!("({},{})", el.pivot_x, el.pivot_y), format!("({},{})", el.pivot_x, el.pivot_y),
el.keyframes.len(), el.keyframes.len(),
@@ -628,42 +585,12 @@ fn cmd_screen_render(
black: bool, black: bool,
all: bool, all: bool,
primitives: bool, primitives: bool,
at: Option<u32>,
settle: bool,
) -> Result<()> { ) -> Result<()> {
use sylpheed_formats::ui_layout::{self, ComposeOptions}; use sylpheed_formats::ui_layout::{self, ComposeOptions};
let builds = screen_builds(pak, all)?; let builds = screen_builds(pak, all)?;
let idx = pick_build(&builds, want)?; let idx = pick_build(&builds, want)?;
let bytes = &builds[idx].1; let bytes = &builds[idx].1;
let b = ui_layout::parse_build(bytes).context("build did not parse")?; let b = ui_layout::parse_build(bytes).context("build did not parse")?;
let at = if settle {
match (b.settle_window(), b.settle_time()) {
(Some((lo, hi)), Some(t)) => {
// Report the width, not just the answer. A 4-unit window and a
// 190-unit one give the same kind of number and mean entirely
// different things.
println!(
"settle window [{lo}, {hi}] = {} units ({:.2} s) -> posing at t={t}{}",
hi - lo,
(hi - lo) as f64 / 60.0,
if hi - lo < 30 {
" ⚠️ narrow — this bundle may never settle"
} else {
""
}
);
Some(t)
}
_ => {
println!(
"no settle window (fewer than two distinct keyframe times) — using rest()"
);
None
}
}
} else {
at
};
let screen = ui_layout::compose( let screen = ui_layout::compose(
&b, &b,
bytes, bytes,
@@ -676,7 +603,6 @@ fn cmd_screen_render(
ComposeOptions::default().backdrop ComposeOptions::default().backdrop
}, },
include_primitives: primitives, include_primitives: primitives,
at,
}, },
None, None,
); );
@@ -697,40 +623,16 @@ fn cmd_screen_render(
screen.height screen.height
); );
if !screen.missing.is_empty() { if !screen.missing.is_empty() {
println!( println!(" sprites that did not resolve/decode: {:?}", screen.missing);
" sprites that did not resolve/decode: {:?}",
screen.missing
);
} }
// 🔴 Report the INDEX, the KIND and WHY, not just the name. A kind-`0x4` let undrawn: Vec<&str> = b
// ghost instance carries its template's name, so a bare name list shows
// `ptlogo1.t32` twice and reads as "the logo is missing" when what is
// skipped is two motion-trail ghosts sitting at alpha 0 off-screen. That
// misreading cost this project a wrong finding sent to another agent.
let undrawn: Vec<String> = b
.elements .elements
.iter() .iter()
.filter(|e| !screen.drawn.contains(&e.index)) .filter(|e| !screen.drawn.contains(&e.index))
.map(|e| { .map(|e| e.name.as_str())
let why = if e.name.ends_with(".prm") {
"untextured primitive, needs --primitives"
} else if e.name.ends_with(".rat") {
"animation, needs --animated"
} else if e.kind == 0x4 {
"kind 0x4 ghost instance"
} else if e.rest().map(|k| k.fade >> 24) == Some(0) {
"transparent at its pose"
} else {
"no reason established"
};
format!("[{}] {} ({why})", e.index, e.name)
})
.collect(); .collect();
if !undrawn.is_empty() { if !undrawn.is_empty() {
println!(" not drawn ({}):", undrawn.len()); println!(" not drawn ({}): {undrawn:?}", undrawn.len());
for u in &undrawn {
println!(" {u}");
}
} }
Ok(()) Ok(())
} }
@@ -738,7 +640,9 @@ fn cmd_screen_render(
// ── save file ──────────────────────────────────────────────────────────────── // ── save file ────────────────────────────────────────────────────────────────
fn cmd_save_info(file: &Path, all: bool) -> Result<()> { fn cmd_save_info(file: &Path, all: bool) -> Result<()> {
use sylpheed_formats::savegame::{self, Confidence, DevelopState, FieldKind, GHAD_LAYOUT}; use sylpheed_formats::savegame::{
self, Confidence, DevelopState, FieldKind, GHAD_LAYOUT,
};
let raw = std::fs::read(file).context("read save")?; let raw = std::fs::read(file).context("read save")?;
let save = savegame::parse(&raw).map_err(|e| anyhow::anyhow!("{e}"))?; let save = savegame::parse(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
@@ -788,11 +692,7 @@ fn cmd_save_info(file: &Path, all: bool) -> Result<()> {
" {} +{:<3} {:<14} {}", " {} +{:<3} {:<14} {}",
mark(f.confidence), mark(f.confidence),
f.offset, f.offset,
if f.name.is_empty() { if f.name.is_empty() { "(unnamed)" } else { f.name },
"(unnamed)"
} else {
f.name
},
value value
); );
if all && !f.note.is_empty() { if all && !f.note.is_empty() {
@@ -801,10 +701,7 @@ fn cmd_save_info(file: &Path, all: bool) -> Result<()> {
} }
let dev = save.develop_state(); let dev = save.develop_state();
let owned = dev let owned = dev.iter().filter(|d| **d == DevelopState::Developed).count();
.iter()
.filter(|d| **d == DevelopState::Developed)
.count();
let ready = dev let ready = dev
.iter() .iter()
.filter(|d| **d == DevelopState::Developable) .filter(|d| **d == DevelopState::Developable)
@@ -848,36 +745,16 @@ fn cmd_audio_info(file: &Path) -> Result<()> {
println!("{} {}", "Audio:".green().bold(), file.display()); println!("{} {}", "Audio:".green().bold(), file.display());
println!(" Codec : {}", info.codec.label().yellow()); println!(" Codec : {}", info.codec.label().yellow());
let opt = |v: Option<String>| v.unwrap_or_else(|| "".dimmed().to_string()); let opt = |v: Option<String>| v.unwrap_or_else(|| "".dimmed().to_string());
println!( println!(" Channels : {}", opt(info.channels.map(|c| c.to_string())));
" Channels : {}", println!(" Sample rate: {}", opt(info.sample_rate.map(|r| format!("{r} Hz"))));
opt(info.channels.map(|c| c.to_string())) println!(" Bit depth : {}", opt(info.bits_per_sample.map(|b| format!("{b}-bit"))));
);
println!(
" Sample rate: {}",
opt(info.sample_rate.map(|r| format!("{r} Hz")))
);
println!(
" Bit depth : {}",
opt(info.bits_per_sample.map(|b| format!("{b}-bit")))
);
if let Some(b) = info.avg_bytes_per_sec {
println!(" Byte rate : {} B/s (declared)", b.to_string().yellow());
}
if let Some(d) = info.duration_secs { if let Some(d) = info.duration_secs {
let how = if info.codec == sylpheed_formats::AudioCodec::Xma { println!(" Duration : {d:.2} s");
" (from the declared byte rate, not decoded)"
} else {
""
};
println!(" Duration : {d:.2} s{how}");
} }
if let Some(p) = info.xma_packets { if let Some(p) = info.xma_packets {
println!(" XMA packets: {} (2048 B each)", p.to_string().yellow()); println!(" XMA packets: {} (2048 B each)", p.to_string().yellow());
} }
println!( println!(" Size : {} bytes", info.size_bytes.to_string().yellow());
" Size : {} bytes",
info.size_bytes.to_string().yellow()
);
if info.codec.needs_decoder() { if info.codec.needs_decoder() {
println!( println!(
" {} decode not supported (needs an XMA2 decoder + the sound-bank descriptor)", " {} decode not supported (needs an XMA2 decoder + the sound-bank descriptor)",
@@ -910,7 +787,7 @@ async fn cmd_extract(iso_path: &Path, output_dir: &Path) -> Result<()> {
ProgressStyle::default_bar() ProgressStyle::default_bar()
.template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}") .template("{spinner:.cyan} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap() .unwrap()
.progress_chars("█▉▊▋▌▍▎▏ "), .progress_chars("█▉▊▋▌▍▎▏ ")
); );
let stats = reader.extract_all(output_dir).await?; let stats = reader.extract_all(output_dir).await?;
@@ -939,11 +816,7 @@ async fn cmd_extract(iso_path: &Path, output_dir: &Path) -> Result<()> {
// ── list ─────────────────────────────────────────────────────────────────── // ── list ───────────────────────────────────────────────────────────────────
async fn cmd_list(iso_path: &Path, filter: Option<String>) -> Result<()> { async fn cmd_list(iso_path: &Path, filter: Option<String>) -> Result<()> {
println!( println!("{} {}", "Listing".green().bold(), iso_path.display().to_string().cyan());
"{} {}",
"Listing".green().bold(),
iso_path.display().to_string().cyan()
);
let mut reader = sylpheed_formats::xiso::open_iso(iso_path).await?; let mut reader = sylpheed_formats::xiso::open_iso(iso_path).await?;
let files = reader.list_all_files().await?; let files = reader.list_all_files().await?;
@@ -988,9 +861,7 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for file in &files { for file in &files {
let Ok(bytes) = assets.read(file) else { let Ok(bytes) = assets.read(file) else { continue; };
continue;
};
let fmt = identify_format(&bytes); let fmt = identify_format(&bytes);
let label = fmt.extension_hint(); let label = fmt.extension_hint();
*counts.entry(label).or_insert(0) += 1; *counts.entry(label).or_insert(0) += 1;
@@ -1011,10 +882,9 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
let hex_preview = if label == "bin" && bytes.len() >= 8 { let hex_preview = if label == "bin" && bytes.len() >= 8 {
format!( format!(
" {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X}", " {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X}",
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7] bytes[0], bytes[1], bytes[2], bytes[3],
) bytes[4], bytes[5], bytes[6], bytes[7]
.dimmed() ).dimmed().to_string()
.to_string()
} else { } else {
String::new() String::new()
}; };
@@ -1028,7 +898,11 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
let mut summary: Vec<_> = counts.into_iter().collect(); let mut summary: Vec<_> = counts.into_iter().collect();
summary.sort_by_key(|&(_, count)| std::cmp::Reverse(count)); summary.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
for (fmt, count) in summary { for (fmt, count) in summary {
println!(" {:>6} .{}", count.to_string().yellow(), fmt); println!(
" {:>6} .{}",
count.to_string().yellow(),
fmt
);
} }
Ok(()) Ok(())
@@ -1037,7 +911,8 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
// ── texture info ────────────────────────────────────────────────────────── // ── texture info ──────────────────────────────────────────────────────────
fn cmd_texture_info(file: &Path) -> Result<()> { fn cmd_texture_info(file: &Path) -> Result<()> {
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?; let bytes = std::fs::read(file)
.with_context(|| format!("Cannot read {}", file.display()))?;
use sylpheed_formats::texture::X360Texture; use sylpheed_formats::texture::X360Texture;
let tex = X360Texture::from_xpr2(&bytes) let tex = X360Texture::from_xpr2(&bytes)
@@ -1045,27 +920,16 @@ fn cmd_texture_info(file: &Path) -> Result<()> {
let d = tex.format.desc(); let d = tex.format.desc();
println!("{} {}", "Texture:".green().bold(), file.display()); println!("{} {}", "Texture:".green().bold(), file.display());
println!( println!(" Resolution : {}×{}", tex.width.to_string().yellow(), tex.height.to_string().yellow());
" Resolution : {}×{}",
tex.width.to_string().yellow(),
tex.height.to_string().yellow()
);
println!( println!(
" Format : {} ({:?}) · {} bpp, {}", " Format : {} ({:?}) · {} bpp, {}",
tex.format.gpu_name().yellow(), tex.format.gpu_name().yellow(),
tex.format, tex.format,
d.bpp, d.bpp,
if d.compressed { if d.compressed { "compressed" } else { "uncompressed" },
"compressed"
} else {
"uncompressed"
},
); );
println!(" Mip levels : {}", tex.mip_levels); println!(" Mip levels : {}", tex.mip_levels);
println!( println!(" Data size : {} bytes", tex.data.len().to_string().yellow());
" Data size : {} bytes",
tex.data.len().to_string().yellow()
);
Ok(()) Ok(())
} }
@@ -1073,12 +937,13 @@ fn cmd_texture_info(file: &Path) -> Result<()> {
// ── texture export ──────────────────────────────────────────────────────── // ── texture export ────────────────────────────────────────────────────────
fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> { fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?; let bytes = std::fs::read(file)
.with_context(|| format!("Cannot read {}", file.display()))?;
use sylpheed_formats::texture::X360Texture; use sylpheed_formats::texture::X360Texture;
let tex = X360Texture::from_xpr2(&bytes)?; let tex = X360Texture::from_xpr2(&bytes)?;
let rgba = let rgba = decode_to_rgba8(&tex)
decode_to_rgba8(&tex).with_context(|| format!("decoding {:?} texture", tex.format))?; .with_context(|| format!("decoding {:?} texture", tex.format))?;
image::save_buffer( image::save_buffer(
output, output,
@@ -1095,11 +960,7 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
tex.width, tex.width,
tex.height, tex.height,
tex.format, tex.format,
if tex.is_cubemap { if tex.is_cubemap { " (cubemap face 0)" } else { "" },
" (cubemap face 0)"
} else {
""
},
output.display().to_string().cyan(), output.display().to_string().cyan(),
); );
Ok(()) Ok(())
@@ -1199,17 +1060,11 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
}; };
let mut maxedges: Vec<f32> = sub let mut maxedges: Vec<f32> = sub
.indices .indices
.as_chunks::<3>() .as_chunks::<3>().0.iter()
.0
.iter()
.map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2]))) .map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2])))
.collect(); .collect();
maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap()); maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = maxedges let median = maxedges.get(maxedges.len() / 2).copied().unwrap_or(1.0).max(1e-6);
.get(maxedges.len() / 2)
.copied()
.unwrap_or(1.0)
.max(1e-6);
let spanning = maxedges.iter().filter(|&&e| e > 6.0 * median).count(); let spanning = maxedges.iter().filter(|&&e| e > 6.0 * median).count();
println!( println!(
" sub{si}: {nv} v, {} tris | degenerate {degen}, unref-verts {unref}, spanning>6×med {spanning}, idx_max {imax}/{}{}", " sub{si}: {nv} v, {} tris | degenerate {degen}, unref-verts {unref}, spanning>6×med {spanning}, idx_max {imax}/{}{}",
@@ -1329,16 +1184,10 @@ fn cmd_mesh_render(
(lo[2] + hi[2]) * 0.5, (lo[2] + hi[2]) * 0.5,
]; ];
let (scale, cell) = if multi { let (scale, cell) = if multi {
let extent = (hi[0] - lo[0]) let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]).max(1e-3);
.max(hi[1] - lo[1])
.max(hi[2] - lo[2])
.max(1e-3);
let col = i % cols; let col = i % cols;
let row = i / cols; let row = i / cols;
( (CELL / extent, [col as f32 * grid_pitch, -(row as f32) * grid_pitch, 0.0])
CELL / extent,
[col as f32 * grid_pitch, -(row as f32) * grid_pitch, 0.0],
)
} else { } else {
(1.0, [0.0, 0.0, 0.0]) (1.0, [0.0, 0.0, 0.0])
}; };
@@ -1375,9 +1224,7 @@ fn cmd_mesh_render(
let med = { let med = {
let mut e: Vec<f32> = sub let mut e: Vec<f32> = sub
.indices .indices
.as_chunks::<3>() .as_chunks::<3>().0.iter()
.0
.iter()
.filter(|t| (t[0] as usize) < n && (t[1] as usize) < n && (t[2] as usize) < n) .filter(|t| (t[0] as usize) < n && (t[1] as usize) < n && (t[2] as usize) < n)
.map(|t| { .map(|t| {
let d = |a: u32, b: u32| { let d = |a: u32, b: u32| {
@@ -1404,9 +1251,7 @@ fn cmd_mesh_render(
}; };
for place in &mine { for place in &mine {
let f = |i: usize| { let f = |i: usize| {
let p = place let p = place.map(|pl| pl.apply(sub.positions[i])).unwrap_or(sub.positions[i]);
.map(|pl| pl.apply(sub.positions[i]))
.unwrap_or(sub.positions[i]);
[ [
(p[0] - center[0]) * scale * mirror[0] + cell[0], (p[0] - center[0]) * scale * mirror[0] + cell[0],
(p[1] - center[1]) * scale * mirror[1] + cell[1], (p[1] - center[1]) * scale * mirror[1] + cell[1],
@@ -1599,9 +1444,7 @@ fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u
// [A,R,G,B] byte order (verified against the retail Acheron backdrop). // [A,R,G,B] byte order (verified against the retail Acheron backdrop).
// Emit RGBA. X8 has no meaningful alpha. // Emit RGBA. X8 has no meaningful alpha.
let opaque = matches!(tex.format, F::X8R8G8B8); let opaque = matches!(tex.format, F::X8R8G8B8);
let src = tex.data.as_chunks::<4>().0; for (px, out) in tex.data.as_chunks::<4>().0.iter().zip(rgba.as_chunks_mut::<4>().0) {
let dst = rgba.as_chunks_mut::<4>().0;
for (px, out) in src.iter().zip(dst) {
out[0] = px[1]; // R out[0] = px[1]; // R
out[1] = px[2]; // G out[1] = px[2]; // G
out[2] = px[3]; // B out[2] = px[3]; // B
@@ -1797,10 +1640,8 @@ fn emit_t8ad(
} }
match t8ad::parse(slice) { match t8ad::parse(slice) {
Some(img) => { Some(img) => {
let out = output.join(format!( let out =
"{hash:08x}_{stem}_{}x{}.png", output.join(format!("{hash:08x}_{stem}_{}x{}.png", img.width, img.height));
img.width, img.height
));
image::save_buffer( image::save_buffer(
&out, &out,
&img.rgba, &img.rgba,
@@ -1822,7 +1663,8 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
use sylpheed_formats::{lsta, ratc, t8ad}; use sylpheed_formats::{lsta, ratc, t8ad};
let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?; let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
std::fs::create_dir_all(output).with_context(|| format!("creating {}", output.display()))?; std::fs::create_dir_all(output)
.with_context(|| format!("creating {}", output.display()))?;
println!( println!(
"{} {}{}", "{} {}{}",
@@ -1890,14 +1732,7 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
} else { } else {
safe_name(&child.name) safe_name(&child.name)
}; };
emit_t8ad( emit_t8ad(&payload[child.offset..end], hash, &stem, output, verbose, &mut stats)?;
&payload[child.offset..end],
hash,
&stem,
output,
verbose,
&mut stats,
)?;
} }
} }
continue; continue;

View File

@@ -55,19 +55,33 @@ license.workspace = true
# a squash-merge can orphan, and no way for the exporter to be built against a # a squash-merge can orphan, and no way for the exporter to be built against a
# decoder it was never tested with. A decoder change and the exporter change it # decoder it was never tested with. A decoder change and the exporter change it
# requires now land in the same commit or not at all. # requires now land in the same commit or not at all.
# ✅ UNPINNED 2026-09-20. The pin above was deliberate and carried its own exit # PINNED BY TAG, which is what MISSION section 2 prescribes and what the tagging
# condition -- "revert to the path dependency the day the tag is an ancestor of # rule exists for: "the RE agent tags when it lands something you need and tells
# `main`" -- because while it held, `sylpheed-cli` built from the WORKSPACE crate # you over the message channel -- that is how you stay current without floating."
# and the exporter from the tag, so `tools/port/verify-screen` compared two eras # That is exactly what happened here.
# instead of detecting drift. `formats-pin-2026-09-01` is now an ancestor of
# `main` (measured), so the two read one decoder again.
# #
# 🔴 AND THE PIN HAD A COST NOBODY PRICED IN: it made this repository depend on # The tag carries the CORRECTED keyframe association: a placement group is an
# its OWN history by tag. The issue-#49 history rewrite replaced every commit, # 8-byte header then `frames` x {u32 time; 36-byte pose}, so pose 0's time is the
# the locked rev vanished, and the build broke for every clean checkout while # group's lead-in word and EVERY POSE IS TIMED, including the last. The working
# still working on the machine that did the rewrite, because its `~/.cargo/git` # tree's copy still has the retired `SYLPHEED_KF_TIME_SHIFT` knob -- a superseded
# still held the old object (PR #60). A path dependency cannot fail that way. # partial fix that got the association right but left pose 0 untimed, which is
sylpheed-formats = { path = "../sylpheed-formats" } # why testing it moved the untimed frame from last to first instead of removing
# it. The old reading is behind `SYLPHEED_KF_TIME_LEGACY=1` here.
#
# 🔴 THE COST, STATED: `sylpheed-cli` builds from the WORKSPACE crate, so until
# this lands on `main` the exporter and the reference renderer read DIFFERENT
# decoders and `tools/port/verify-screen` is comparing two eras rather than
# detecting drift. `tools/port/verify-capture` is unaffected -- it compares the
# port against oracle CAPTURES and never touches the CLI -- and it is the check
# that matters. Revert to the path dependency the day the tag is an ancestor of
# `main`.
# Bumped c -> d 2026-08-29. What I wanted from the new state: `d` carries parser
# and `audio.rs` changes on top of `c`. ⚠️ Its headline change -- Reborn's
# renderer drawing `rotation_deg`, and `compose` drawing a leaf that carries
# geometry -- does NOT reach this port from here: `sylpheed-cli` builds from the
# WORKSPACE crate, so the reference renderer stays unrotated until the tag lands
# on `main`. This bump is for the parser, not for the renderer.
sylpheed-formats = { git = "https://git.mc02.dev/fabi/Sylpheed.git", tag = "formats-pin-2026-09-01" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View File

@@ -7,8 +7,7 @@ use std::process::Command;
use sylpheed_formats::media; use sylpheed_formats::media;
fn main() { fn main() {
let disc = let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let src = media::DirectorySource::new(&disc); let src = media::DirectorySource::new(&disc);
for bank in ["BGM_103.slb", "BGM_102.slb", "BGM_001.slb"] { for bank in ["BGM_103.slb", "BGM_102.slb", "BGM_001.slb"] {
match media::sound_bank_riffs(&src, bank) { match media::sound_bank_riffs(&src, bank) {
@@ -20,42 +19,22 @@ fn main() {
let w = std::env::temp_dir().join(format!("bk_{i}.wav")); let w = std::env::temp_dir().join(format!("bk_{i}.wav"));
let _ = Command::new("ffmpeg") let _ = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]) .args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(&p) .arg(&p).arg(&w).output();
.arg(&w)
.output();
let out = Command::new("ffmpeg") let out = Command::new("ffmpeg")
.args(["-hide_banner", "-v", "info", "-i"]) .args(["-hide_banner", "-v", "info", "-i"])
.arg(&w) .arg(&w)
.args(["-af", "astats=measure_perchannel=none", "-f", "null", "-"]) .args(["-af", "astats=measure_perchannel=none", "-f", "null", "-"])
.output() .output().unwrap();
.unwrap();
let t = String::from_utf8_lossy(&out.stderr).into_owned(); let t = String::from_utf8_lossy(&out.stderr).into_owned();
let get = |k: &str| { let get = |k: &str| t.lines().find_map(|l| l.split_once(k).map(|x| x.1.trim().to_string()))
t.lines() .unwrap_or_else(|| "?".into());
.find_map(|l| l.split_once(k).map(|x| x.1.trim().to_string()))
.unwrap_or_else(|| "?".into())
};
let dur = Command::new("ffprobe") let dur = Command::new("ffprobe")
.args([ .args(["-v","error","-show_entries","format=duration","-of","csv=p=0"])
"-v", .arg(&w).output().ok()
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
])
.arg(&w)
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default(); .unwrap_or_default();
println!( println!(" sub-wave {i}: {:>9} B -> {:>10} s peak {:>10} rms {}",
" sub-wave {i}: {:>9} B -> {:>10} s peak {:>10} rms {}", r.len(), dur, get("Peak level dB:"), get("RMS level dB:"));
r.len(),
dur,
get("Peak level dB:"),
get("RMS level dB:")
);
let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(&w); let _ = std::fs::remove_file(&w);
} }

View File

@@ -18,26 +18,19 @@
use sylpheed_formats::media; use sylpheed_formats::media;
fn main() { fn main() {
let root = let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let src = media::DirectorySource::new(&root); let src = media::DirectorySource::new(&root);
const WANT: [usize; 2] = [3_876_864, 3_930_112]; const WANT: [usize; 2] = [3_876_864, 3_930_112];
let (mut found, mut matches) = (0usize, Vec::new()); let (mut found, mut matches) = (0usize, Vec::new());
for n in 0..=199u32 { for n in 0..=199u32 {
let name = format!("BGM_{n:03}.slb"); let name = format!("BGM_{n:03}.slb");
let Ok(riffs) = media::sound_bank_riffs(&src, &name) else { let Ok(riffs) = media::sound_bank_riffs(&src, &name) else { continue };
continue; if riffs.is_empty() { continue }
};
if riffs.is_empty() {
continue;
}
found += 1; found += 1;
let sizes: Vec<usize> = riffs.iter().map(|r| r.len()).collect(); let sizes: Vec<usize> = riffs.iter().map(|r| r.len()).collect();
// Compare on the DATA payload the port sums, not on the RIFF wrapper: // Compare on the DATA payload the port sums, not on the RIFF wrapper:
// a wrapper differs by header bytes and would hide a real collision. // a wrapper differs by header bytes and would hide a real collision.
let near = sizes let near = sizes.iter().any(|s| WANT.iter().any(|w| s.abs_diff(*w) < 4096));
.iter()
.any(|s| WANT.iter().any(|w| s.abs_diff(*w) < 4096));
if near { if near {
matches.push((name.clone(), sizes.clone())); matches.push((name.clone(), sizes.clone()));
} }
@@ -46,10 +39,7 @@ fn main() {
for (n, s) in &matches { for (n, s) in &matches {
println!(" {n:<14} wave sizes {s:?}"); println!(" {n:<14} wave sizes {s:?}");
} }
println!( println!("\n {} bank(s) carry a wave within 4 KiB of {WANT:?}", matches.len());
"\n {} bank(s) carry a wave within 4 KiB of {WANT:?}",
matches.len()
);
println!(" Exactly 1 means the census EXCLUDES alternatives and is a real third"); println!(" Exactly 1 means the census EXCLUDES alternatives and is a real third");
println!(" leg. More than 1 means the byte match does not distinguish BGM_103,"); println!(" leg. More than 1 means the byte match does not distinguish BGM_103,");
println!(" and \"three legs\" is two. Zero means this reader cannot see the"); println!(" and \"three legs\" is two. Zero means this reader cannot see the");

View File

@@ -16,40 +16,27 @@
//! //!
//! This prints what the differences actually look like, so the reading is judged //! This prints what the differences actually look like, so the reading is judged
//! against the names rather than accepted as plausible. //! against the names rather than accepted as plausible.
use std::collections::BTreeSet;
use sylpheed_formats::{pak, ratc, ui_layout}; use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeSet;
fn main() { fn main() {
let root = let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let ar = pak::PakArchive::open(format!("{root}/dat/GP_DIALOG.pak")).expect("GP_DIALOG.pak"); let ar = pak::PakArchive::open(format!("{root}/dat/GP_DIALOG.pak")).expect("GP_DIALOG.pak");
let sets: Vec<Option<BTreeSet<String>>> = ar let sets: Vec<Option<BTreeSet<String>>> = ar.entries().iter().map(|e| {
.entries() let by = ar.read(e).ok()?;
.iter() if !ratc::is_ratc(&by) { return None }
.map(|e| { let b = ui_layout::parse_build(&by)?;
let by = ar.read(e).ok()?; Some(b.elements.iter().map(|el| el.name.clone()).collect())
if !ratc::is_ratc(&by) { }).collect();
return None;
}
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|el| el.name.clone()).collect())
})
.collect();
let (mut same, mut diff, mut pairs) = (0usize, 0usize, 0usize); let (mut same, mut diff, mut pairs) = (0usize, 0usize, 0usize);
let mut shown = 0; let mut shown = 0;
for i in (0..sets.len().saturating_sub(1)).step_by(2) { for i in (0..sets.len().saturating_sub(1)).step_by(2) {
let (Some(a), Some(b)) = (&sets[i], &sets[i + 1]) else { let (Some(a), Some(b)) = (&sets[i], &sets[i + 1]) else { continue };
continue;
};
pairs += 1; pairs += 1;
if a == b { if a == b {
same += 1; same += 1;
println!( println!(" entries {i:>3}/{:<3} IDENTICAL sets, {} element(s)", i + 1, a.len());
" entries {i:>3}/{:<3} IDENTICAL sets, {} element(s)",
i + 1,
a.len()
);
continue; continue;
} }
diff += 1; diff += 1;
@@ -62,37 +49,19 @@ fn main() {
if (10..=15).contains(&i) { if (10..=15).contains(&i) {
let sp = |x: &BTreeSet<String>| x.iter().filter(|n| n.ends_with(".t32")).count(); let sp = |x: &BTreeSet<String>| x.iter().filter(|n| n.ends_with(".t32")).count();
let stage = |x: &BTreeSet<String>| -> Vec<String> { let stage = |x: &BTreeSet<String>| -> Vec<String> {
let mut v: Vec<String> = x let mut v: Vec<String> = x.iter().filter_map(|n| n.strip_prefix("pzstg")
.iter() .and_then(|r| r.get(..2)).map(|s| s.to_string())).collect();
.filter_map(|n| { v.sort(); v.dedup(); v
n.strip_prefix("pzstg")
.and_then(|r| r.get(..2))
.map(|s| s.to_string())
})
.collect();
v.sort();
v.dedup();
v
}; };
println!( println!(" entries {i:>3}/{:<3} stages {:?} vs {:?} sprites {} vs {}",
" entries {i:>3}/{:<3} stages {:?} vs {:?} sprites {} vs {}", i + 1, stage(a), stage(b), sp(a), sp(b));
i + 1,
stage(a),
stage(b),
sp(a),
sp(b)
);
} }
if shown < 3 { if shown < 3 {
shown += 1; shown += 1;
let only_a: Vec<_> = a.difference(b).cloned().collect(); let only_a: Vec<_> = a.difference(b).cloned().collect();
let only_b: Vec<_> = b.difference(a).cloned().collect(); let only_b: Vec<_> = b.difference(a).cloned().collect();
println!( println!(" entries {i:>3}/{:<3} differ: {} only-in-first, {} only-in-second",
" entries {i:>3}/{:<3} differ: {} only-in-first, {} only-in-second", i + 1, only_a.len(), only_b.len());
i + 1,
only_a.len(),
only_b.len()
);
println!(" first : {:?}", &only_a[..only_a.len().min(4)]); println!(" first : {:?}", &only_a[..only_a.len().min(4)]);
println!(" second : {:?}", &only_b[..only_b.len().min(4)]); println!(" second : {:?}", &only_b[..only_b.len().min(4)]);
} }
@@ -102,17 +71,12 @@ fn main() {
// different dialogs and the whole adjacent-pairing premise is wrong -- which // different dialogs and the whole adjacent-pairing premise is wrong -- which
// is a stronger statement than "the language reading is untested". // is a stronger statement than "the language reading is untested".
let btns = |s: &Option<BTreeSet<String>>| -> usize { let btns = |s: &Option<BTreeSet<String>>| -> usize {
s.as_ref() s.as_ref().map_or(0, |x| x.iter().filter(|n| n.contains("btn")).count())
.map_or(0, |x| x.iter().filter(|n| n.contains("btn")).count())
}; };
let mut mismatched = 0; let mut mismatched = 0;
for i in (0..sets.len().saturating_sub(1)).step_by(2) { for i in (0..sets.len().saturating_sub(1)).step_by(2) {
if sets[i].is_none() || sets[i + 1].is_none() { if sets[i].is_none() || sets[i + 1].is_none() { continue }
continue; if btns(&sets[i]) != btns(&sets[i + 1]) { mismatched += 1 }
}
if btns(&sets[i]) != btns(&sets[i + 1]) {
mismatched += 1
}
} }
println!("\n adjacent pairs whose BUTTON COUNTS differ: {mismatched}"); println!("\n adjacent pairs whose BUTTON COUNTS differ: {mismatched}");
println!(" A language pair cannot. Every one of these is two different dialogs."); println!(" A language pair cannot. Every one of these is two different dialogs.");

View File

@@ -17,8 +17,7 @@
use sylpheed_formats::{pak, ratc, ui_layout}; use sylpheed_formats::{pak, ratc, ui_layout};
fn main() { fn main() {
let root = let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
// 🔴 WIDENED 2026-08-31 to every pak, to check the Decoder's rival search // 🔴 WIDENED 2026-08-31 to every pak, to check the Decoder's rival search
// independently. They report zero four-button builds within 6 px of // independently. They report zero four-button builds within 6 px of
// 259/329/399/469 anywhere on the disc, which turns "another dialog with // 259/329/399/469 anywhere on the disc, which turns "another dialog with
@@ -27,66 +26,40 @@ fn main() {
// with a different reader, because its whole content is an absence. // with a different reader, because its whole content is an absence.
const WANT: [i32; 4] = [259, 329, 399, 469]; const WANT: [i32; 4] = [259, 329, 399, 469];
const TOL: i32 = 6; const TOL: i32 = 6;
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")) let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
.expect("dat/") .flatten().map(|e| e.path())
.flatten() .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort(); paks.sort();
let (mut hits, mut scanned) = (0usize, 0usize); let (mut hits, mut scanned) = (0usize, 0usize);
for path in &paks { for path in &paks {
let Ok(ar) = pak::PakArchive::open(path) else { let Ok(ar) = pak::PakArchive::open(path) else { continue };
continue; let arch = path.file_name().unwrap().to_string_lossy().to_string();
}; for (i, e) in ar.entries().iter().enumerate() {
let arch = path.file_name().unwrap().to_string_lossy().to_string(); let Ok(by) = ar.read(e) else { continue };
for (i, e) in ar.entries().iter().enumerate() { if !ratc::is_ratc(&by) { continue }
let Ok(by) = ar.read(e) else { continue }; let Some(b) = ui_layout::parse_build(&by) else { continue };
if !ratc::is_ratc(&by) { scanned += 1;
continue; // Any button-shaped record, not just `pcbtn`: a rival need not share the
} // naming convention, and restricting by name would answer a narrower
let Some(b) = ui_layout::parse_build(&by) else { // question than the one asked.
continue; let mut rows: Vec<(String, i32)> = b.elements.iter()
}; .filter(|el| el.name.contains("btn"))
scanned += 1; .filter_map(|el| el.rest().map(|r| (el.name.clone(), r.y)))
// Any button-shaped record, not just `pcbtn`: a rival need not share the .collect();
// naming convention, and restricting by name would answer a narrower if rows.is_empty() { continue }
// question than the one asked. rows.sort_by(|a, b| a.1.cmp(&b.1));
let mut rows: Vec<(String, i32)> = b let ys: Vec<i32> = rows.iter().map(|r| r.1).collect();
.elements let gaps: Vec<i32> = ys.windows(2).map(|w| w[1] - w[0]).collect();
.iter() if rows.len() == 4 && ys.iter().zip(WANT.iter()).all(|(a, b)| (a - b).abs() <= TOL) {
.filter(|el| el.name.contains("btn")) hits += 1;
.filter_map(|el| el.rest().map(|r| (el.name.clone(), r.y))) println!(" {arch} entry {i:>2} {} record(s): {}", rows.len(),
.collect(); rows.iter().map(|r| r.0.as_str()).collect::<Vec<_>>().join(" "));
if rows.is_empty() { println!(" rows {ys:?} gaps {gaps:?}");
continue;
}
rows.sort_by_key(|r| r.1);
let ys: Vec<i32> = rows.iter().map(|r| r.1).collect();
let gaps: Vec<i32> = ys.windows(2).map(|w| w[1] - w[0]).collect();
if rows.len() == 4
&& ys
.iter()
.zip(WANT.iter())
.all(|(a, b)| (a - b).abs() <= TOL)
{
hits += 1;
println!(
" {arch} entry {i:>2} {} record(s): {}",
rows.len(),
rows.iter()
.map(|r| r.0.as_str())
.collect::<Vec<_>>()
.join(" ")
);
println!(" rows {ys:?} gaps {gaps:?}");
}
} }
} }
println!( }
"\n {scanned} build(s) scanned across {} pak(s); {hits} match the", println!("\n {scanned} build(s) scanned across {} pak(s); {hits} match the",
paks.len() paks.len());
);
println!(" DIFFICULTY row signature within +/-{TOL} px."); println!(" DIFFICULTY row signature within +/-{TOL} px.");
println!(" Expected: exactly 2 -- the EN/JP pair. More means a RIVAL exists and"); println!(" Expected: exactly 2 -- the EN/JP pair. More means a RIVAL exists and");
println!(" the geometric identification is not unique; fewer means this reader"); println!(" the geometric identification is not unique; fewer means this reader");

View File

@@ -8,49 +8,31 @@
use sylpheed_formats::{pak::PakArchive, ui_layout}; use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() { fn main() {
let disc = let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let ar = PakArchive::open(format!("{disc}/dat/GP_TITLE.pak")).expect("open"); let ar = PakArchive::open(format!("{disc}/dat/GP_TITLE.pak")).expect("open");
let e = &ar.entries()[4]; // entry 4 = the English title let e = &ar.entries()[4]; // entry 4 = the English title
let bundle = ar.read(e).expect("read"); let bundle = ar.read(e).expect("read");
let b = ui_layout::parse_build(&bundle).expect("parse"); let b = ui_layout::parse_build(&bundle).expect("parse");
println!( println!("build has {} elements, {} records", b.elements.len(), b.records.len());
"build has {} elements, {} records",
b.elements.len(),
b.records.len()
);
let mut names: Vec<&String> = b.records.keys().collect(); let mut names: Vec<&String> = b.records.keys().collect();
names.sort(); names.sort();
println!("records: {names:?}"); println!("records: {names:?}");
for el in &b.elements { for el in &b.elements {
if !el.name.starts_with("ptloop") { if !el.name.starts_with("ptloop") { continue; }
continue;
}
let r = el.rest(); let r = el.rest();
println!( println!("\nPARENT {} sprite={:?} -> rest scale {:?} rot {:?}", el.name, el.sprite,
"\nPARENT {} sprite={:?} -> rest scale {:?} rot {:?}", r.map(|r| (r.scale_x, r.scale_y)), r.map(|r| r.rotation_deg));
el.name,
el.sprite,
r.map(|r| (r.scale_x, r.scale_y)),
r.map(|r| r.rotation_deg)
);
if let Some(&(off, size)) = b.records.get(&el.name) { if let Some(&(off, size)) = b.records.get(&el.name) {
match ui_layout::parse_build(&bundle[off..off + size]) { match ui_layout::parse_build(&bundle[off..off + size]) {
Some(leaf) => { Some(leaf) => {
println!( println!(" LEAF {} parses: {} element(s)", el.name, leaf.elements.len());
" LEAF {} parses: {} element(s)",
el.name,
leaf.elements.len()
);
for le in &leaf.elements { for le in &leaf.elements {
let lr = le.rest(); let lr = le.rest();
println!( println!(" {:<20} rest scale {:?} rot {:?} pos {:?}",
" {:<20} rest scale {:?} rot {:?} pos {:?}",
le.name, le.name,
lr.map(|r| (r.scale_x, r.scale_y)), lr.map(|r| (r.scale_x, r.scale_y)),
lr.map(|r| r.rotation_deg), lr.map(|r| r.rotation_deg),
lr.map(|r| (r.x, r.y)) lr.map(|r| (r.x, r.y)));
);
for k in &le.keyframes { for k in &le.keyframes {
println!(" t={:?} scale=({},{}) rot={} pos=({},{}) fade={:#010x} u4={} u8={}", println!(" t={:?} scale=({},{}) rot={} pos=({},{}) fade={:#010x} u4={} u8={}",
k.time, k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y, k.time, k.scale_x, k.scale_y, k.rotation_deg, k.x, k.y,

View File

@@ -26,14 +26,15 @@
//! and adds the one they could not run: the same two, restricted to the records //! and adds the one they could not run: the same two, restricted to the records
//! **this port actually animates**. A disc-wide 0.00 % violation rate says //! **this port actually animates**. A disc-wide 0.00 % violation rate says
//! nothing about my six screens if all six sit in the exceptional tail. //! nothing about my six screens if all six sit in the exceptional tail.
use std::collections::BTreeMap;
use sylpheed_formats::{pak, ratc, ui_layout}; use sylpheed_formats::{pak, ratc, ui_layout};
use std::collections::BTreeMap;
/// The records the port animates: the plate glow, the five menu focus records, /// The records the port animates: the plate glow, the five menu focus records,
/// and the title's two sweeps. Named rather than pattern-matched, because the /// and the title's two sweeps. Named rather than pattern-matched, because the
/// point is to check the ones that are shipped, not the ones that match a glob. /// point is to check the ones that are shipped, not the ones that match a glob.
const SHIPPED: &[&str] = &[ const SHIPPED: &[&str] = &[
"ptbtn00f", "ptbtn01f", "ptbtn02f", "ptbtn03f", "ptbtn04f", "ptbtn05f", "ptloop01", "ptloop02", "ptbtn00f", "ptbtn01f", "ptbtn02f", "ptbtn03f", "ptbtn04f", "ptbtn05f",
"ptloop01", "ptloop02",
]; ];
/// Which header word to read as the loop length. `0x08` is the decoded one; /// Which header word to read as the loop length. `0x08` is the decoded one;
@@ -43,19 +44,14 @@ const SHIPPED: &[&str] = &[
static mut OFFSET: usize = 8; static mut OFFSET: usize = 8;
fn main() { fn main() {
let off: usize = std::env::args() let off: usize = std::env::args().find_map(|a| a.strip_prefix("--offset=")
.find_map(|a| a.strip_prefix("--offset=").and_then(|v| v.parse().ok())) .and_then(|v| v.parse().ok())).unwrap_or(8);
.unwrap_or(8);
unsafe { OFFSET = off }; unsafe { OFFSET = off };
println!(" reading the loop length at header +0x{off:02x}"); println!(" reading the loop length at header +0x{off:02x}");
let root = let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root"); let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")) .flatten().map(|e| e.path())
.expect("dat/") .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort(); paks.sort();
let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize); let (mut total, mut exact, mut holds, mut violations) = (0usize, 0usize, 0usize, 0usize);
@@ -63,24 +59,14 @@ fn main() {
let mut shipped: BTreeMap<String, (i64, i64)> = BTreeMap::new(); let mut shipped: BTreeMap<String, (i64, i64)> = BTreeMap::new();
for p in &paks { for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { let Ok(ar) = pak::PakArchive::open(p) else { continue };
continue;
};
for e in ar.entries() { for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue }; let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { if !ratc::is_ratc(&by) { continue }
continue; let Some(b) = ui_layout::parse_build(&by) else { continue };
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for (rn, &(o, s)) in &b.records { for (rn, &(o, s)) in &b.records {
if o + off + 4 > by.len() || o + s > by.len() { if o + off + 4 > by.len() || o + s > by.len() { continue }
continue; if &by[o..o + 4] != b"RATC" { continue }
}
if &by[o..o + 4] != b"RATC" {
continue;
}
// 🔴 THE FALSIFIER IS RUN AT NEIGHBOURING OFFSETS TOO. The // 🔴 THE FALSIFIER IS RUN AT NEIGHBOURING OFFSETS TOO. The
// Decoder's struct-layout control showed that a homogeneous // Decoder's struct-layout control showed that a homogeneous
// repeated table type-checks at every field boundary, so an // repeated table type-checks at every field boundary, so an
@@ -91,28 +77,15 @@ fn main() {
// "confirmation" without asking whether it discriminates the // "confirmation" without asking whether it discriminates the
// OFFSET or merely the file. // OFFSET or merely the file.
let len = u32::from_be_bytes(by[o + off..o + off + 4].try_into().unwrap()) as i64; let len = u32::from_be_bytes(by[o + off..o + off + 4].try_into().unwrap()) as i64;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
continue; let maxt = lb.elements.iter()
};
let maxt = lb
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)) .flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max() .max().unwrap_or(0) as i64;
.unwrap_or(0) as i64; if maxt == 0 { continue } // static: declares no cycle at all
if maxt == 0 {
continue;
} // static: declares no cycle at all
total += 1; total += 1;
let slack = len - maxt; let slack = len - maxt;
*slack_hist.entry(slack).or_default() += 1; *slack_hist.entry(slack).or_default() += 1;
if slack == 0 { if slack == 0 { exact += 1 } else if slack > 0 { holds += 1 } else { violations += 1 }
exact += 1
} else if slack > 0 {
holds += 1
} else {
violations += 1
}
let stem = rn.trim_end_matches(".rat"); let stem = rn.trim_end_matches(".rat");
if SHIPPED.contains(&stem) { if SHIPPED.contains(&stem) {
shipped.entry(stem.to_string()).or_insert((len, maxt)); shipped.entry(stem.to_string()).or_insert((len, maxt));
@@ -122,68 +95,36 @@ fn main() {
} }
println!("disc-wide, records with timed keyframes: {total}"); println!("disc-wide, records with timed keyframes: {total}");
println!( println!(" +08 == max t (exact) : {exact:5} {:5.1} %", pc(exact, total));
" +08 == max t (exact) : {exact:5} {:5.1} %", println!(" +08 > max t (a hold) : {holds:5} {:5.1} %", pc(holds, total));
pc(exact, total) println!(" +08 < max t <- FALSIFIER : {violations:5} {:5.2} %", pc(violations, total));
);
println!(
" +08 > max t (a hold) : {holds:5} {:5.1} %",
pc(holds, total)
);
println!(
" +08 < max t <- FALSIFIER : {violations:5} {:5.2} %",
pc(violations, total)
);
println!("\nslack distribution, most common first:"); println!("\nslack distribution, most common first:");
let mut h: Vec<_> = slack_hist.iter().collect(); let mut h: Vec<_> = slack_hist.iter().collect();
h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n)); h.sort_by_key(|&(_, n)| std::cmp::Reverse(*n));
for (k, n) in h.iter().take(8) { for (k, n) in h.iter().take(8) { println!(" slack {k:>6} : {n}"); }
println!(" slack {k:>6} : {n}");
}
println!("\nthe records THIS PORT animates:"); println!("\nthe records THIS PORT animates:");
println!( println!(" {:<12} {:>6} {:>7} {:>7}", "record", "+0x08", "max t", "slack");
" {:<12} {:>6} {:>7} {:>7}",
"record", "+0x08", "max t", "slack"
);
let (mut ship_exact, mut ship_hold, mut ship_bad) = (0, 0, 0); let (mut ship_exact, mut ship_hold, mut ship_bad) = (0, 0, 0);
for (n, (len, maxt)) in &shipped { for (n, (len, maxt)) in &shipped {
let slack = len - maxt; let slack = len - maxt;
match slack { match slack { 0 => ship_exact += 1, s if s > 0 => ship_hold += 1, _ => ship_bad += 1 }
0 => ship_exact += 1, println!(" {n:<12} {len:>6} {maxt:>7} {slack:>7}{}",
s if s > 0 => ship_hold += 1, if slack < 0 { " 🔴 FALSIFIED" } else { "" });
_ => ship_bad += 1,
}
println!(
" {n:<12} {len:>6} {maxt:>7} {slack:>7}{}",
if slack < 0 { " 🔴 FALSIFIED" } else { "" }
);
} }
println!("\n shipped: {ship_exact} exact, {ship_hold} hold, {ship_bad} falsified"); println!("\n shipped: {ship_exact} exact, {ship_hold} hold, {ship_bad} falsified");
if shipped.len() < SHIPPED.len() { if shipped.len() < SHIPPED.len() {
let missing: Vec<_> = SHIPPED let missing: Vec<_> = SHIPPED.iter().filter(|s| !shipped.contains_key(**s)).collect();
.iter()
.filter(|s| !shipped.contains_key(**s))
.collect();
println!(" ⚠️ not found on the disc: {missing:?} -- a name the port ships and"); println!(" ⚠️ not found on the disc: {missing:?} -- a name the port ships and");
println!(" this control never checked is worse than a violation it found."); println!(" this control never checked is worse than a violation it found.");
} }
println!( println!("\n verdict: {}", if ship_bad > 0 {
"\n verdict: {}", "🔴 the reading fails on a record the port animates -- do NOT adopt"
if ship_bad > 0 { } else if ship_hold == 0 {
"🔴 the reading fails on a record the port animates -- do NOT adopt" "⚠️ every shipped record is exact, so this port cannot tell loop length\n from max keyframe time -- adopting 120 would change nothing here"
} else if ship_hold == 0 { } else {
"⚠️ every shipped record is exact, so this port cannot tell loop length\n from max keyframe time -- adopting 120 would change nothing here" "✅ falsifier clean and the field is non-trivial ON THE SHIPPED SET"
} else { });
"✅ falsifier clean and the field is non-trivial ON THE SHIPPED SET"
}
);
} }
fn pc(n: usize, d: usize) -> f64 { fn pc(n: usize, d: usize) -> f64 { if d == 0 { 0.0 } else { 100.0 * n as f64 / d as f64 } }
if d == 0 {
0.0
} else {
100.0 * n as f64 / d as f64
}
}

View File

@@ -11,49 +11,30 @@
use sylpheed_formats::{pak, ratc, ui_layout}; use sylpheed_formats::{pak, ratc, ui_layout};
fn main() { fn main() {
let root = let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root"); let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")).expect("dat/")
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat")) .flatten().map(|e| e.path())
.expect("dat/") .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak")).collect();
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort(); paks.sort();
let (mut records, mut in_bounds, mut magic, mut parsed, mut timed) = (0, 0, 0, 0, 0); let (mut records, mut in_bounds, mut magic, mut parsed, mut timed) = (0, 0, 0, 0, 0);
let (mut untimed, mut all_at_zero) = (0usize, 0usize); let (mut untimed, mut all_at_zero) = (0usize, 0usize);
for p in &paks { for p in &paks {
let Ok(ar) = pak::PakArchive::open(p) else { let Ok(ar) = pak::PakArchive::open(p) else { continue };
continue;
};
for e in ar.entries() { for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue }; let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { if !ratc::is_ratc(&by) { continue }
continue; let Some(b) = ui_layout::parse_build(&by) else { continue };
} for (_, &(o, s)) in &b.records {
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for &(o, s) in b.records.values() {
records += 1; records += 1;
if o + 12 > by.len() || o + s > by.len() { if o + 12 > by.len() || o + s > by.len() { continue }
continue;
}
in_bounds += 1; in_bounds += 1;
if &by[o..o + 4] != b"RATC" { if &by[o..o + 4] != b"RATC" { continue }
continue;
}
magic += 1; magic += 1;
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
continue;
};
parsed += 1; parsed += 1;
let maxt = lb let maxt = lb.elements.iter()
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)) .flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max() .max().unwrap_or(0);
.unwrap_or(0);
// 🔴 `maxt == 0` merges two different populations, and the // 🔴 `maxt == 0` merges two different populations, and the
// Decoder's cause -- `.max()` returning `Some(0)` -- is only one // Decoder's cause -- `.max()` returning `Some(0)` -- is only one
// of them. A record with NO timed keyframe has no largest // of them. A record with NO timed keyframe has no largest
@@ -62,16 +43,10 @@ fn main() {
// content. Both of us called all 1 530 "the question has no // content. Both of us called all 1 530 "the question has no
// meaning"; that is true of one group and an assumption about // meaning"; that is true of one group and an assumption about
// the other. // the other.
let any_timed = lb let any_timed = lb.elements.iter()
.elements
.iter()
.any(|el| el.keyframes.iter().any(|k| k.time.is_some())); .any(|el| el.keyframes.iter().any(|k| k.time.is_some()));
if maxt == 0 { if maxt == 0 {
if any_timed { if any_timed { all_at_zero += 1 } else { untimed += 1 }
all_at_zero += 1
} else {
untimed += 1
}
continue; continue;
} }
timed += 1; timed += 1;
@@ -80,10 +55,8 @@ fn main() {
} }
println!(" records declared by parse_build : {records}"); println!(" records declared by parse_build : {records}");
println!(" within the entry's bounds : {in_bounds}"); println!(" within the entry's bounds : {in_bounds}");
println!( println!(" carrying the RATC magic : {magic} <- {} dropped here",
" carrying the RATC magic : {magic} <- {} dropped here", in_bounds - magic);
in_bounds - magic
);
println!(" parsing as a nested build : {parsed}"); println!(" parsing as a nested build : {parsed}");
println!(" with a largest keyframe time > 0: {timed}"); println!(" with a largest keyframe time > 0: {timed}");
println!(" of the {} excluded:", untimed + all_at_zero); println!(" of the {} excluded:", untimed + all_at_zero);

View File

@@ -11,31 +11,18 @@
use sylpheed_formats::{pak, ratc, ui_layout}; use sylpheed_formats::{pak, ratc, ui_layout};
fn main() { fn main() {
let root = let root = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let ar = pak::PakArchive::open(format!("{root}/dat/GP_TITLE.pak")).expect("GP_TITLE.pak"); let ar = pak::PakArchive::open(format!("{root}/dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut total, mut hits, mut multipose) = (0usize, 0usize, 0usize); let (mut total, mut hits, mut multipose) = (0usize, 0usize, 0usize);
for (i, e) in ar.entries().iter().enumerate() { for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue }; let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) { if !ratc::is_ratc(&by) { continue }
continue; let Some(b) = ui_layout::parse_build(&by) else { continue };
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for (name, &(o, s)) in &b.records { for (name, &(o, s)) in &b.records {
if o + 12 > by.len() || o + s > by.len() || &by[o..o + 4] != b"RATC" { if o + 12 > by.len() || o + s > by.len() || &by[o..o + 4] != b"RATC" { continue }
continue; let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { continue };
} let maxt = lb.elements.iter()
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else { .flat_map(|el| el.keyframes.iter().filter_map(|k| k.time)).max().unwrap_or(0);
continue;
};
let maxt = lb
.elements
.iter()
.flat_map(|el| el.keyframes.iter().filter_map(|k| k.time))
.max()
.unwrap_or(0);
let len = ui_layout::loop_length_units(&by[o..o + s]).unwrap_or(0); let len = ui_layout::loop_length_units(&by[o..o + s]).unwrap_or(0);
total += 1; total += 1;
if maxt == 0 && len > 0 { if maxt == 0 && len > 0 {
@@ -44,19 +31,10 @@ fn main() {
// to move between. All-at-t=0 with a single keyframe per element // to move between. All-at-t=0 with a single keyframe per element
// is visually inert however it is played. // is visually inert however it is played.
let kf: usize = lb.elements.iter().map(|el| el.keyframes.len()).sum(); let kf: usize = lb.elements.iter().map(|el| el.keyframes.len()).sum();
let multi = lb let multi = lb.elements.iter().filter(|el| el.keyframes.len() > 1).count();
.elements if multi > 0 { multipose += 1 }
.iter() println!(" entry {i:>2} {name:<16} {len}-unit cycle, {kf} keyframe(s) \
.filter(|el| el.keyframes.len() > 1) across {} element(s), {multi} with >1 pose", lb.elements.len());
.count();
if multi > 0 {
multipose += 1
}
println!(
" entry {i:>2} {name:<16} {len}-unit cycle, {kf} keyframe(s) \
across {} element(s), {multi} with >1 pose",
lb.elements.len()
);
} }
} }
} }

View File

@@ -7,8 +7,7 @@ use std::process::Command;
use sylpheed_formats::{media, slb::VoiceLang}; use sylpheed_formats::{media, slb::VoiceLang};
fn main() { fn main() {
let disc = let disc = std::env::var("SYLPHEED_DISC").unwrap_or_else(|_| "/disc".into());
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let src = media::DirectorySource::new(&disc); let src = media::DirectorySource::new(&disc);
for movie in ["ADV", "S00A", "RT01A"] { for movie in ["ADV", "S00A", "RT01A"] {
let Some((s, e)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English) let Some((s, e)) = media::resolve_movie_voice_region(&src, movie, VoiceLang::English)
@@ -17,11 +16,7 @@ fn main() {
continue; continue;
}; };
let riffs = media::voice_region_riffs(&src, s, e).expect("riffs"); let riffs = media::voice_region_riffs(&src, s, e).expect("riffs");
println!( println!("{movie}: region [{s}, {e}) = {} bytes, {} chunk(s)", e - s, riffs.len());
"{movie}: region [{s}, {e}) = {} bytes, {} chunk(s)",
e - s,
riffs.len()
);
for (i, r) in riffs.iter().enumerate() { for (i, r) in riffs.iter().enumerate() {
let p = std::env::temp_dir().join(format!("vc_{movie}_{i}.xma.wav")); let p = std::env::temp_dir().join(format!("vc_{movie}_{i}.xma.wav"));
std::fs::write(&p, r).unwrap(); std::fs::write(&p, r).unwrap();
@@ -33,14 +28,7 @@ fn main() {
.arg(&w) .arg(&w)
.output(); .output();
let out = Command::new("ffprobe") let out = Command::new("ffprobe")
.args([ .args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"])
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
])
.arg(&w) .arg(&w)
.output() .output()
.unwrap(); .unwrap();

View File

@@ -169,9 +169,10 @@ pub fn load(authored: &Path) -> Result<Option<Config>> {
#[serde(default)] #[serde(default)]
voice: BTreeMap<String, serde_json::Value>, voice: BTreeMap<String, serde_json::Value>,
} }
let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; let raw = std::fs::read_to_string(&path)
let file: File = .with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?; let file: File = serde_json::from_str(&raw)
.with_context(|| format!("parse {}", path.display()))?;
// `_` is the house convention for a prose block explaining the section it // `_` is the house convention for a prose block explaining the section it
// sits in -- see `authored/flow.json` and `authored/timing.json`. It is // sits in -- see `authored/flow.json` and `authored/timing.json`. It is
@@ -210,12 +211,9 @@ pub fn load(authored: &Path) -> Result<Option<Config>> {
let size: usize = k let size: usize = k
.parse() .parse()
.with_context(|| format!("authored/audio.json: voice.stream_weights key {k}"))?; .with_context(|| format!("authored/audio.json: voice.stream_weights key {k}"))?;
let w = v let w = v.get("weight").and_then(serde_json::Value::as_f64).with_context(|| {
.get("weight") format!("authored/audio.json: voice.stream_weights.{k} has no numeric weight")
.and_then(serde_json::Value::as_f64) })?;
.with_context(|| {
format!("authored/audio.json: voice.stream_weights.{k} has no numeric weight")
})?;
stream_weights.insert(size, w); stream_weights.insert(size, w);
} }
} }
@@ -284,16 +282,8 @@ fn run_ffmpeg(argv: &[String], out: &Path) -> Result<()> {
// temp name -- it is a hard failure before a byte is written: "Unable to // temp name -- it is a hard failure before a byte is written: "Unable to
// choose an output format". `video.rs` already had this shape; this // choose an output format". `video.rs` already had this shape; this
// function was written from scratch and did not. // function was written from scratch and did not.
let stem = out let stem = out.file_stem().unwrap_or_default().to_string_lossy().into_owned();
.file_stem() let ext = out.extension().unwrap_or_default().to_string_lossy().into_owned();
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let ext = out
.extension()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let partial = out.with_file_name(format!(".{stem}.partial.{ext}")); let partial = out.with_file_name(format!(".{stem}.partial.{ext}"));
let mut argv = argv.to_vec(); let mut argv = argv.to_vec();
let last = argv.len() - 1; let last = argv.len() - 1;
@@ -338,12 +328,8 @@ fn measure(path: &Path) -> (Option<f32>, Option<f32>) {
.find_map(|l| l.split_once(KEY)?.1.trim().parse().ok()); .find_map(|l| l.split_once(KEY)?.1.trim().parse().ok());
let dur = Command::new("ffprobe") let dur = Command::new("ffprobe")
.args([ .args([
"-v", "-v", "error", "-show_entries", "format=duration",
"error", "-of", "csv=p=0",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
]) ])
.arg(path) .arg(path)
.output() .output()
@@ -382,12 +368,7 @@ pub fn export_cues<S: DiscSource + ?Sized>(
// short read rather than returning a truncated stream, because a // short read rather than returning a truncated stream, because a
// truncated XMA decodes to plausible-sounding garbage. // truncated XMA decodes to plausible-sounding garbage.
let riff = media::se_wave_riff( let riff = media::se_wave_riff(
source, source, &cue.bank, offset, cue.packets, cue.channels, cue.rate,
&cue.bank,
offset,
cue.packets,
cue.channels,
cue.rate,
) )
.map_err(anyhow::Error::msg) .map_err(anyhow::Error::msg)
.with_context(|| format!("assemble the {event} cue"))?; .with_context(|| format!("assemble the {event} cue"))?;
@@ -395,16 +376,9 @@ pub fn export_cues<S: DiscSource + ?Sized>(
let staged = stage_riff(&dir, event, &riff)?; let staged = stage_riff(&dir, event, &riff)?;
let ogg = dir.join(format!("{event}.ogg")); let ogg = dir.join(format!("{event}.ogg"));
let argv: Vec<String> = [ let argv: Vec<String> = [
"-hide_banner", "-hide_banner", "-loglevel", "error", "-y",
"-loglevel", "-i", &staged.display().to_string(),
"error", "-c:a", "libvorbis", "-q:a", VORBIS_Q,
"-y",
"-i",
&staged.display().to_string(),
"-c:a",
"libvorbis",
"-q:a",
VORBIS_Q,
&ogg.display().to_string(), &ogg.display().to_string(),
] ]
.iter() .iter()
@@ -563,15 +537,9 @@ pub fn export_bgm<S: DiscSource + ?Sized>(
argv.push(format!("{end}")); argv.push(format!("{end}"));
} }
argv.extend( argv.extend(
[ ["-c:a", "libvorbis", "-q:a", VORBIS_Q, &ogg.display().to_string()]
"-c:a", .iter()
"libvorbis", .map(|s| s.to_string()),
"-q:a",
VORBIS_Q,
&ogg.display().to_string(),
]
.iter()
.map(|s| s.to_string()),
); );
let command = format!("ffmpeg {}", argv.join(" ")); let command = format!("ffmpeg {}", argv.join(" "));
run_ffmpeg(&argv, &ogg)?; run_ffmpeg(&argv, &ogg)?;
@@ -943,10 +911,7 @@ pub fn export_voice<S: DiscSource + ?Sized>(
// 238-packet late start was found. Applied positionally instead, the // 238-packet late start was found. Applied positionally instead, the
// weights would have gone onto the wrong streams in silence. // weights would have gone onto the wrong streams in silence.
let sizes: Vec<usize> = keep.iter().map(|&i| riffs[i].len() - RIFF_HEADER).collect(); let sizes: Vec<usize> = keep.iter().map(|&i| riffs[i].len() - RIFF_HEADER).collect();
let ws: Option<Vec<f64>> = sizes let ws: Option<Vec<f64>> = sizes.iter().map(|s| stream_weights.get(s).copied()).collect();
.iter()
.map(|s| stream_weights.get(s).copied())
.collect();
match ws { match ws {
Some(w) if w.len() == staged.len() => { Some(w) if w.len() == staged.len() => {
// Weights sum to one, so the total is the movie's own and what // Weights sum to one, so the total is the movie's own and what
@@ -972,15 +937,9 @@ pub fn export_voice<S: DiscSource + ?Sized>(
argv.push("-map".into()); argv.push("-map".into());
argv.push("[a]".into()); argv.push("[a]".into());
argv.extend( argv.extend(
[ ["-c:a", "libvorbis", "-q:a", VORBIS_Q, &ogg.display().to_string()]
"-c:a", .iter()
"libvorbis", .map(|s| s.to_string()),
"-q:a",
VORBIS_Q,
&ogg.display().to_string(),
]
.iter()
.map(|s| s.to_string()),
); );
let command = format!("ffmpeg {}", argv.join(" ")); let command = format!("ffmpeg {}", argv.join(" "));
run_ffmpeg(&argv, &ogg)?; run_ffmpeg(&argv, &ogg)?;
@@ -1080,14 +1039,8 @@ pub fn export_voice<S: DiscSource + ?Sized>(
fn probe_channels(path: &Path) -> Option<u8> { fn probe_channels(path: &Path) -> Option<u8> {
let out = Command::new("ffprobe") let out = Command::new("ffprobe")
.args([ .args([
"-v", "-v", "error", "-select_streams", "a:0",
"error", "-show_entries", "stream=channels", "-of", "csv=p=0",
"-select_streams",
"a:0",
"-show_entries",
"stream=channels",
"-of",
"csv=p=0",
]) ])
.arg(path) .arg(path)
.output() .output()
@@ -1103,12 +1056,8 @@ fn probe_channels(path: &Path) -> Option<u8> {
pub fn probe_duration(path: &Path) -> Option<f32> { pub fn probe_duration(path: &Path) -> Option<f32> {
let out = Command::new("ffprobe") let out = Command::new("ffprobe")
.args([ .args([
"-v", "-v", "error", "-show_entries", "format=duration",
"error", "-of", "csv=p=0",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
]) ])
.arg(path) .arg(path)
.output() .output()
@@ -1145,6 +1094,7 @@ fn decoded_chunk(riff: &Path) -> (f32, f32) {
out out
} }
/// Which channel indices of a decoded stream are not digitally silent. /// Which channel indices of a decoded stream are not digitally silent.
/// ///
/// `astats` reports per-channel blocks: a `Channel: N` line followed by that /// `astats` reports per-channel blocks: a `Channel: N` line followed by that

View File

@@ -50,9 +50,8 @@ impl Ctx {
/// name. Anything else means a consumer has to guess, which is the whole thing /// name. Anything else means a consumer has to guess, which is the whole thing
/// the format exists to prevent. /// the format exists to prevent.
fn is_hex32(v: Option<&Value>) -> bool { fn is_hex32(v: Option<&Value>) -> bool {
v.and_then(Value::as_str).is_some_and(|s| { v.and_then(Value::as_str)
s.len() == 10 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit()) .is_some_and(|s| s.len() == 10 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit()))
})
} }
fn check_pose(c: &mut Ctx, where_: &str, p: &Value) { fn check_pose(c: &mut Ctx, where_: &str, p: &Value) {
@@ -90,17 +89,12 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
// an invented one, so the provenance is mandatory and closed. // an invented one, so the provenance is mandatory and closed.
match v.get("name_source").and_then(Value::as_str) { match v.get("name_source").and_then(Value::as_str) {
Some("authored") => { Some("authored") => {
if v.get("name_why") if v.get("name_why").and_then(Value::as_str).is_none_or(str::is_empty) {
.and_then(Value::as_str)
.is_none_or(str::is_empty)
{
c.err("name_source is `authored` but there is no `name_why`"); c.err("name_source is `authored` but there is no `name_why`");
} }
} }
Some("index") => {} Some("index") => {}
other => c.err(format!( other => c.err(format!("name_source must be `authored` or `index`, got {other:?}")),
"name_source must be `authored` or `index`, got {other:?}"
)),
} }
if let Some(s) = v.get("source") { if let Some(s) = v.get("source") {
for key in ["archive", "entry", "build"] { for key in ["archive", "entry", "build"] {
@@ -125,22 +119,9 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
let mut indices = Vec::new(); let mut indices = Vec::new();
let mut buttons_by_y: Vec<(i64, String)> = Vec::new(); let mut buttons_by_y: Vec<(i64, String)> = Vec::new();
for (i, el) in elements.iter().enumerate() { for (i, el) in elements.iter().enumerate() {
let id = el let id = el.get("id").and_then(Value::as_str).unwrap_or("<no id>").to_string();
.get("id")
.and_then(Value::as_str)
.unwrap_or("<no id>")
.to_string();
let at = format!("element {i} ({id})"); let at = format!("element {i} ({id})");
for key in [ for key in ["index", "id", "declared", "role", "kind_raw", "pivot", "layer_source", "keyframes"] {
"index",
"id",
"declared",
"role",
"kind_raw",
"pivot",
"layer_source",
"keyframes",
] {
if el.get(key).is_none() { if el.get(key).is_none() {
c.err(format!("{at}: missing `{key}`")); c.err(format!("{at}: missing `{key}`"));
} }
@@ -150,9 +131,7 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
continue; continue;
}; };
if idx as usize != i { if idx as usize != i {
c.err(format!( c.err(format!("{at}: `index` {idx} does not match its position {i}"));
"{at}: `index` {idx} does not match its position {i}"
));
} }
indices.push(idx as usize); indices.push(idx as usize);
@@ -173,21 +152,15 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
match el.get("layer_source").and_then(Value::as_str) { match el.get("layer_source").and_then(Value::as_str) {
Some("sprite") | Some("implied") => { Some("sprite") | Some("implied") => {
if !is_hex32(el.get("layer")) { if !is_hex32(el.get("layer")) {
c.err(format!( c.err(format!("{at}: layer_source claims a key but `layer` is not one"));
"{at}: layer_source claims a key but `layer` is not one"
));
} }
} }
Some("none") => { Some("none") => {
if el.get("layer").is_some() { if el.get("layer").is_some() {
c.err(format!( c.err(format!("{at}: layer_source `none` but a `layer` is present"));
"{at}: layer_source `none` but a `layer` is present"
));
} }
} }
other => c.err(format!( other => c.err(format!("{at}: layer_source must be sprite/implied/none, got {other:?}")),
"{at}: layer_source must be sprite/implied/none, got {other:?}"
)),
} }
for key in ["sprite", "focus_sprite"] { for key in ["sprite", "focus_sprite"] {
@@ -196,9 +169,7 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
if !path.exists() { if !path.exists() {
c.err(format!("{at}: `{key}` points at {p}, which does not exist")); c.err(format!("{at}: `{key}` points at {p}, which does not exist"));
} else if let Err(e) = image::open(&path) { } else if let Err(e) = image::open(&path) {
c.err(format!( c.err(format!("{at}: `{key}` {p} does not decode as an image: {e}"));
"{at}: `{key}` {p} does not decode as an image: {e}"
));
} }
} }
} }
@@ -206,11 +177,7 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
if let Some(r) = el.get("rest") { if let Some(r) = el.get("rest") {
check_pose(&mut c, &at, r); check_pose(&mut c, &at, r);
if role == "button" { if role == "button" {
if let Some(y) = r if let Some(y) = r.get("pos").and_then(Value::as_array).and_then(|a| a[1].as_i64()) {
.get("pos")
.and_then(Value::as_array)
.and_then(|a| a[1].as_i64())
{
buttons_by_y.push((y, id.clone())); buttons_by_y.push((y, id.clone()));
} }
} }
@@ -247,10 +214,7 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
// either drops an element or draws one twice. // either drops an element or draws one twice.
match v.get("paint_order").and_then(Value::as_array) { match v.get("paint_order").and_then(Value::as_array) {
Some(po) => { Some(po) => {
let mut got: Vec<usize> = po let mut got: Vec<usize> = po.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect();
.iter()
.filter_map(|x| x.as_u64().map(|v| v as usize))
.collect();
if got.len() != po.len() { if got.len() != po.len() {
c.err("`paint_order` holds a non-integer"); c.err("`paint_order` holds a non-integer");
} }
@@ -292,10 +256,7 @@ fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()>
pub fn run(root: &Path) -> Result<usize> { pub fn run(root: &Path) -> Result<usize> {
let manifest_path = root.join("manifest.json"); let manifest_path = root.join("manifest.json");
if !manifest_path.exists() { if !manifest_path.exists() {
bail!( bail!("{} has no manifest.json — is that an export tree?", root.display());
"{} has no manifest.json — is that an export tree?",
root.display()
);
} }
let m: Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?; let m: Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?;
let mut errors = Vec::new(); let mut errors = Vec::new();
@@ -367,13 +328,9 @@ fn check_audio(root: &Path, m: &Value, errors: &mut Vec<String>) {
errors.push(format!("manifest.json: audio `{name}` has no `{key}`")); errors.push(format!("manifest.json: audio `{name}` has no `{key}`"));
} }
} }
let Some(file) = a.get("file").and_then(Value::as_str) else { let Some(file) = a.get("file").and_then(Value::as_str) else { continue };
continue;
};
if !root.join(file).exists() { if !root.join(file).exists() {
errors.push(format!( errors.push(format!("manifest.json: lists audio {file}, which does not exist"));
"manifest.json: lists audio {file}, which does not exist"
));
continue; continue;
} }
match a.get("peak_dbfs").and_then(Value::as_f64) { match a.get("peak_dbfs").and_then(Value::as_f64) {

View File

@@ -14,8 +14,8 @@
mod audio; mod audio;
mod check; mod check;
mod screen;
mod video; mod video;
mod screen;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
@@ -164,7 +164,8 @@ fn load_names(authored: &Path) -> Result<NameMap> {
#[serde(default)] #[serde(default)]
also_export: AlsoExport, also_export: AlsoExport,
} }
let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
Ok(serde_json::from_str::<File>(&raw) Ok(serde_json::from_str::<File>(&raw)
.with_context(|| format!("parse {}", path.display()))? .with_context(|| format!("parse {}", path.display()))?
.archives) .archives)
@@ -172,7 +173,8 @@ fn load_names(authored: &Path) -> Result<NameMap> {
/// Extra pak entries to export that `is_build` does not accept, keyed by /// Extra pak entries to export that `is_build` does not accept, keyed by
/// archive. AUTHORED, and each carries its own `why`. /// archive. AUTHORED, and each carries its own `why`.
type AlsoExport = std::collections::BTreeMap<String, std::collections::BTreeMap<String, NameEntry>>; type AlsoExport =
std::collections::BTreeMap<String, std::collections::BTreeMap<String, NameEntry>>;
fn load_also_export(authored: &Path) -> Result<AlsoExport> { fn load_also_export(authored: &Path) -> Result<AlsoExport> {
let path = authored.join("screen_names.json"); let path = authored.join("screen_names.json");
@@ -184,7 +186,8 @@ fn load_also_export(authored: &Path) -> Result<AlsoExport> {
#[serde(default)] #[serde(default)]
also_export: AlsoExport, also_export: AlsoExport,
} }
let raw = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
Ok(serde_json::from_str::<File>(&raw) Ok(serde_json::from_str::<File>(&raw)
.with_context(|| format!("parse {}", path.display()))? .with_context(|| format!("parse {}", path.display()))?
.also_export) .also_export)
@@ -208,10 +211,9 @@ fn load_also_export(authored: &Path) -> Result<AlsoExport> {
/// exactly four bundles and all four are real screens, with zero fragments. In /// exactly four bundles and all four are real screens, with zero fragments. In
/// another archive it would not be, which is why this is an allow-list and not /// another archive it would not be, which is why this is an allow-list and not
/// a widened predicate. /// a widened predicate.
fn screen_builds( fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap<String, NameEntry>>)
ar: &PakArchive, -> Vec<(usize, Vec<u8>)>
also: Option<&std::collections::BTreeMap<String, NameEntry>>, {
) -> Vec<(usize, Vec<u8>)> {
let mut out = Vec::new(); let mut out = Vec::new();
for (i, e) in ar.entries().iter().enumerate() { for (i, e) in ar.entries().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue }; let Ok(bytes) = ar.read(e) else { continue };
@@ -232,11 +234,7 @@ fn main() -> Result<()> {
} => run_export(&disc, &out, &authored), } => run_export(&disc, &out, &authored),
Cmd::Check { out } => { Cmd::Check { out } => {
let n = check::run(&out)?; let n = check::run(&out)?;
println!( println!("{} screen(s) in {} validate against sylpheed.screen/3", n, out.display());
"{} screen(s) in {} validate against sylpheed.screen/3",
n,
out.display()
);
Ok(()) Ok(())
} }
} }
@@ -396,7 +394,12 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
Some(cfg) => { Some(cfg) => {
let source = media::DirectorySource::new(disc); let source = media::DirectorySource::new(disc);
for a in audio::export_cues(&source, out, &cfg.se)? { for a in audio::export_cues(&source, out, &cfg.se)? {
println!(" se {:<8} -> {} ({})", a.name, a.file, describe(&a)); println!(
" se {:<8} -> {} ({})",
a.name,
a.file,
describe(&a)
);
audio.push(ManifestAudio::from(a)); audio.push(ManifestAudio::from(a));
} }
for (role, spec) in &cfg.bgm { for (role, spec) in &cfg.bgm {
@@ -458,10 +461,7 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
// there is no `authored/audio.json` -- the voice binding is decoded, // there is no `authored/audio.json` -- the voice binding is decoded,
// so the dialogue exports either way and only the choice defaults. // so the dialogue exports either way and only the choice defaults.
let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default(); let want = audio_cfg.as_ref().map(|c| c.voice).unwrap_or_default();
let weights = audio_cfg let weights = audio_cfg.as_ref().map(|c| c.stream_weights.clone()).unwrap_or_default();
.as_ref()
.map(|c| c.stream_weights.clone())
.unwrap_or_default();
match audio::export_voice(&source, out, stem, *len, want, &weights)? { match audio::export_voice(&source, out, stem, *len, want, &weights)? {
Some(a) => { Some(a) => {
// 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The // 🔴 A TOP-LEVEL WARNING, not just a `why` on the entry. The
@@ -517,6 +517,7 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
impl From<audio::Exported> for ManifestAudio { impl From<audio::Exported> for ManifestAudio {
fn from(a: audio::Exported) -> Self { fn from(a: audio::Exported) -> Self {
ManifestAudio { ManifestAudio {
@@ -559,6 +560,7 @@ fn describe(a: &audio::Exported) -> String {
} }
} }
/// Delete anything in `video/` this run did not produce. /// Delete anything in `video/` this run did not produce.
/// ///
/// `video/` is the one directory the wholesale wipe spares, so that the /// `video/` is the one directory the wholesale wipe spares, so that the

View File

@@ -416,6 +416,7 @@ pub fn export_build(
Ok(true) Ok(true)
} }
/// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`. /// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`.
fn highlight_name(sprite: &str) -> Option<String> { fn highlight_name(sprite: &str) -> Option<String> {
let (stem, ext) = sprite.rsplit_once('.')?; let (stem, ext) = sprite.rsplit_once('.')?;
@@ -459,9 +460,7 @@ pub fn export_build(
written: &mut std::collections::BTreeMap<String, ()>, written: &mut std::collections::BTreeMap<String, ()>,
missing: &mut Vec<String>| missing: &mut Vec<String>|
-> Result<Option<Focus>> { -> Result<Option<Focus>> {
let Some(&(off, size)) = b.records.get(rec) else { let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
return Ok(None);
};
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else { let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
return Ok(None); return Ok(None);
}; };
@@ -469,13 +468,8 @@ pub fn export_build(
for fe in &leaf.elements { for fe in &leaf.elements {
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name); let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
let mut fsprite = None; let mut fsprite = None;
if write_from( if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
&sprite_dir, || write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
written,
sp,
&bundle[off..off + size],
&leaf.sprites,
)? || write_from(&sprite_dir, written, sp, bundle, &b.sprites)?
{ {
fsprite = Some(sprite_rel(sp)); fsprite = Some(sprite_rel(sp));
} else if sp.ends_with(".t32") { } else if sp.ends_with(".t32") {
@@ -487,11 +481,8 @@ pub fn export_build(
declared: fe.name.clone(), declared: fe.name.clone(),
sprite: fsprite, sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name( blend_additive: ui_layout::blend_additive_by_name(
&leaf, &leaf, &bundle[off..off + size], sp)
&bundle[off..off + size], .or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
sp,
)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y], pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest { rest: Rest {
pos: [r.x, r.y], pos: [r.x, r.y],
@@ -542,13 +533,9 @@ pub fn export_build(
// into the leaf slice) or in the parent's. // into the leaf slice) or in the parent's.
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name); let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
let mut fsprite = None; let mut fsprite = None;
if write_from( if write_from(&sprite_dir, &mut written, sp,
&sprite_dir, &bundle[off..off + size], &leaf.sprites)?
&mut written, || write_from(&sprite_dir, &mut written, sp, bundle, &b.sprites)?
sp,
&bundle[off..off + size],
&leaf.sprites,
)? || write_from(&sprite_dir, &mut written, sp, bundle, &b.sprites)?
{ {
fsprite = Some(sprite_rel(sp)); fsprite = Some(sprite_rel(sp));
} else if sp.ends_with(".t32") { } else if sp.ends_with(".t32") {
@@ -560,11 +547,8 @@ pub fn export_build(
declared: fe.name.clone(), declared: fe.name.clone(),
sprite: fsprite, sprite: fsprite,
blend_additive: ui_layout::blend_additive_by_name( blend_additive: ui_layout::blend_additive_by_name(
&leaf, &leaf, &bundle[off..off + size], sp)
&bundle[off..off + size], .or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
sp,
)
.or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)),
pivot: [fe.pivot_x, fe.pivot_y], pivot: [fe.pivot_x, fe.pivot_y],
rest: Rest { rest: Rest {
pos: [r.x, r.y], pos: [r.x, r.y],
@@ -591,9 +575,7 @@ pub fn export_build(
if !fes.is_empty() { if !fes.is_empty() {
focus = Some(Focus { focus = Some(Focus {
record: rec, record: rec,
loop_length_units: ui_layout::loop_length_units( loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]),
&bundle[off..off + size],
),
elements: fes, elements: fes,
}); });
} }
@@ -722,6 +704,7 @@ pub fn export_build(
}) })
} }
/// The longest interval containing no keyframe time, over TOP-LEVEL elements. /// The longest interval containing no keyframe time, over TOP-LEVEL elements.
/// ///
/// See [`Screen::settle_window`] for why this is the settled instant and why /// See [`Screen::settle_window`] for why this is the settled instant and why
@@ -767,12 +750,12 @@ fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
Some([a, b, (a + b) / 2]) Some([a, b, (a + b) / 2])
} }
/// Alpha of one element at instant `t`, under the linear ramp the port uses. /// Alpha of one element at instant `t`, under the linear ramp the port uses.
fn alpha_at(e: &Element, t: i64) -> u8 { fn alpha_at(e: &Element, t: i64) -> u8 {
let ks = &e.keyframes; let ks = &e.keyframes;
let a = |k: &Keyframe| { let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16)
(u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16).unwrap_or(0) >> 24) as i64 .unwrap_or(0) >> 24) as i64;
};
let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect(); let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect();
if timed.is_empty() { if timed.is_empty() {
return 0; return 0;
@@ -905,9 +888,7 @@ fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32;
.filter_map(|k| k.t) .filter_map(|k| k.t)
.map(i64::from) .map(i64::from)
.collect(); .collect();
let Some(&lo) = span.first() else { let Some(&lo) = span.first() else { return false };
return false;
};
// 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`. // 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`.
// Declared size alone is not what the element draws: scale is a // Declared size alone is not what the element draws: scale is a
// percent per axis and it animates. `pbafc.prm` is the disc's own // percent per axis and it animates. `pbafc.prm` is the disc's own
@@ -940,10 +921,9 @@ fn forced_backdrop_first(order: Vec<usize>, elements: &[Element], design: [u32;
return false; return false;
} }
// Every OTHER element must be visible somewhere inside that span. // Every OTHER element must be visible somewhere inside that span.
elements elements.iter().enumerate().all(|(j, o)| {
.iter() j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0)
.enumerate() })
.all(|(j, o)| j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0))
}) })
.map(|(i, _)| i) .map(|(i, _)| i)
.collect(); .collect();

View File

@@ -97,22 +97,13 @@ const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL
fn channels(src: &Path) -> Result<u32> { fn channels(src: &Path) -> Result<u32> {
let out = Command::new("ffprobe") let out = Command::new("ffprobe")
.args([ .args([
"-v", "-v", "error", "-select_streams", "a:0",
"error", "-show_entries", "stream=channels", "-of", "csv=p=0",
"-select_streams",
"a:0",
"-show_entries",
"stream=channels",
"-of",
"csv=p=0",
]) ])
.arg(src) .arg(src)
.output() .output()
.context("run ffprobe -- is it on PATH?")?; .context("run ffprobe -- is it on PATH?")?;
Ok(String::from_utf8_lossy(&out.stdout) Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2))
.trim()
.parse()
.unwrap_or(2))
} }
/// Duration and frame rate of a finished transcode, straight from the file. /// Duration and frame rate of a finished transcode, straight from the file.
@@ -128,8 +119,7 @@ fn probe_timebase(out: &Path) -> (f64, f64) {
if stream { if stream {
c.args(["-select_streams", "v:0"]); c.args(["-select_streams", "v:0"]);
} }
c.args(["-show_entries", entries, "-of", "csv=p=0"]) c.args(["-show_entries", entries, "-of", "csv=p=0"]).arg(out);
.arg(out);
c.output() c.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default() .unwrap_or_default()
@@ -146,20 +136,10 @@ fn probe_timebase(out: &Path) -> (f64, f64) {
fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> { fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> {
let mut v: Vec<String> = [ let mut v: Vec<String> = [
"-hide_banner", "-hide_banner", "-loglevel", "error", "-y",
"-loglevel", "-i", &src.display().to_string(),
"error", "-c:v", "libtheora", "-q:v", "8",
"-y", "-c:a", "libvorbis", "-q:a", "5",
"-i",
&src.display().to_string(),
"-c:v",
"libtheora",
"-q:v",
"8",
"-c:a",
"libvorbis",
"-q:a",
"5",
] ]
.iter() .iter()
.map(|s| s.to_string()) .map(|s| s.to_string())
@@ -288,10 +268,7 @@ pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded
// updates it. The explanation would be correct in the source and absent on // updates it. The explanation would be correct in the source and absent on
// disc, which is the same shape as every other documented-but-unexercised // disc, which is the same shape as every other documented-but-unexercised
// thing this port has had to find the hard way. // thing this port has had to find the hard way.
if std::fs::read_to_string(&stamp) if std::fs::read_to_string(&stamp).map(|s| s != want).unwrap_or(true) {
.map(|s| s != want)
.unwrap_or(true)
{
std::fs::write(&stamp, &want)?; std::fs::write(&stamp, &want)?;
} }
let (duration_s, fps) = probe_timebase(&ogv); let (duration_s, fps) = probe_timebase(&ogv);

View File

@@ -11,12 +11,7 @@ xdvdfs = { workspace = true }
binrw = { workspace = true } binrw = { workspace = true }
flate2 = "1" # zlib/DEFLATE for IPFB "Z1" entries (miniz_oxide backend, WASM-safe) flate2 = "1" # zlib/DEFLATE for IPFB "Z1" entries (miniz_oxide backend, WASM-safe)
ttf-parser = { version = "0.24", default-features = false, features = ["std", "opentype-layout"] } # font metadata (OTF/TTF/ttcf), WASM-safe ttf-parser = { version = "0.24", default-features = false, features = ["std", "opentype-layout"] } # font metadata (OTF/TTF/ttcf), WASM-safe
# tokio is a DEV dependency only (see [dev-dependencies] below). Every use in tokio = { workspace = true }
# this crate is inside a `mod tests`: three runtime builders in ship.rs and one
# `#[tokio::test]` in xiso.rs. As a normal dependency it pulled `tokio/full`,
# whose `net` feature drags in `mio`, which does not build for wasm32 —
# error: This wasm target is unsupported by mio.
# so the unused dependency was breaking the WASM job.
futures = { workspace = true } futures = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }

View File

@@ -1,92 +0,0 @@
use sylpheed_formats::{pak, ui_layout};
fn main() {
let mut a = std::env::args().skip(1);
let pk = a.next().unwrap();
let i: usize = a.next().unwrap().parse().unwrap();
let ar = pak::PakArchive::open(pk).unwrap();
let by = ar.read(&ar.entries()[i]).unwrap();
let b = ui_layout::parse_build(&by).unwrap();
println!(
"entry {i}: {} elements, {} records, {} sprites",
b.elements.len(),
b.records.len(),
b.sprites.len()
);
let mut rk: Vec<&String> = b.records.keys().collect();
rk.sort();
println!(" records: {:?}", rk);
for (rn, &(o, sz)) in &b.records {
if o + sz > by.len() {
continue;
}
let Some(lb) = ui_layout::parse_build(&by[o..o + sz]) else {
continue;
};
println!(" RECORD {rn}: {} elements", lb.elements.len());
for le in &lb.elements {
let lts: Vec<String> = le
.keyframes
.iter()
.map(|k| {
format!(
"t{}a{}",
k.time.map(|v| v as i64).unwrap_or(-1),
k.fade >> 24
)
})
.collect();
println!(
" [{}] {:<22} kind=0x{:<6x} {}",
le.index,
le.name,
le.kind,
lts.join(" ")
);
}
}
for e in &b.elements {
let ts: Vec<String> = e
.keyframes
.iter()
.map(|k| {
format!(
"t{}a{}",
k.time.map(|v| v as i64).unwrap_or(-1),
k.fade >> 24
)
})
.collect();
println!(
" [{}] {:<24} kind=0x{:<6x} {}",
e.index,
e.name,
e.kind,
ts.join(" ")
);
if let Some(&(o, s)) = b.records.get(&e.name) {
if o + s <= by.len() {
if let Some(lb) = ui_layout::parse_build(&by[o..o + s]) {
for le in &lb.elements {
let lts: Vec<String> = le
.keyframes
.iter()
.map(|k| {
format!(
"t{}a{}",
k.time.map(|v| v as i64).unwrap_or(-1),
k.fade >> 24
)
})
.collect();
println!(
" leaf {:<20} kind=0x{:<6x} {}",
le.name,
le.kind,
lts.join(" ")
);
}
}
}
}
}
}

View File

@@ -1,27 +0,0 @@
use sylpheed_formats::{pak, ui_layout};
fn main() {
let mut a = std::env::args().skip(1);
let pk = a.next().unwrap();
let ar = pak::PakArchive::open(pk).unwrap();
for t in a {
let i: usize = t.parse().unwrap();
let by = ar.read(&ar.entries()[i]).unwrap();
let b = ui_layout::parse_build(&by).unwrap();
println!("=== entry {i} ===");
let order = ui_layout::derived_paint_order(&b, &by);
for e in &b.elements {
let k = ui_layout::sprite_layer_key(&b, &by, e);
let pos = order.iter().position(|&x| x == e.index);
println!(
" [{}] {:<24} kind=0x{:<5x} sprite={:<24} key={:<12} paint#{:?}",
e.index,
e.name,
e.kind,
e.sprite.clone().unwrap_or_else(|| "<none>".into()),
k.map(|v| format!("0x{v:08x}"))
.unwrap_or_else(|| "NONE".into()),
pos
);
}
}
}

View File

@@ -1,88 +0,0 @@
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let ar = pak::PakArchive::open(format!("{root}/dat/GP_READY_ROOM.pak")).unwrap();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let Some(p) = b.elements.iter().find(|el| el.name == "pbafc.prm") else {
continue;
};
println!("=== entry {i}: {} elements ===", b.elements.len());
println!(
" pbafc.prm pivot=({},{}) -> {}x{}",
p.pivot_x,
p.pivot_y,
p.pivot_x * 2,
p.pivot_y * 2
);
for k in &p.keyframes {
println!(
" t={:<5} fade={:08x} a={:<4} xy=({},{}) s={}/{}",
k.time.map(|v| v as i64).unwrap_or(-1),
k.fade,
k.fade >> 24,
k.x,
k.y,
k.scale_x,
k.scale_y
);
}
// what does it cover, and is anything visible while it is opaque?
let rest = p.rest().unwrap();
let (px, py, pw, ph) = (
rest.x,
rest.y,
(p.pivot_x * 2) as i32,
(p.pivot_y * 2) as i32,
);
println!(" its rect at rest: ({px},{py}) {pw}x{ph}");
let tmax = b
.elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
.max()
.unwrap_or(0);
let op: Vec<u32> = (0..=tmax)
.filter(|&t| p.pose_at(t).map(|k| k.fade >> 24) == Some(255))
.collect();
println!(
" opaque at {} instants (t={:?}..{:?}) of 0..{tmax}",
op.len(),
op.first(),
op.last()
);
let mut cov = 0;
let mut vis = 0;
for o in &b.elements {
if o.index == p.index {
continue;
}
let Some(ok) = o.rest() else { continue };
let (ow, oh) = ((o.pivot_x * 2) as i32, (o.pivot_y * 2) as i32);
let overlap = (px + pw).min(ok.x + ow) - px.max(ok.x) > 0
&& (py + ph).min(ok.y + oh) - py.max(ok.y) > 0;
if !overlap {
continue;
}
cov += 1;
if op
.iter()
.any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)
{
vis += 1;
if vis <= 6 {
println!(" covered AND visible while opaque: {}", o.name);
}
}
}
println!(" elements its rect covers: {cov}; visible while it is opaque: {vis}");
break;
}
}

View File

@@ -1,38 +0,0 @@
use sylpheed_formats::{pak, ui_layout};
fn main() {
let mut a = std::env::args().skip(1);
let pk = a.next().unwrap();
let ar = pak::PakArchive::open(pk).unwrap();
for t in a {
let i: usize = t.parse().unwrap();
let by = ar.read(&ar.entries()[i]).unwrap();
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let top = u32::from_be_bytes(by[8..12].try_into().unwrap());
println!("entry {i:2} TOP +08 = {top}");
let mut ks: Vec<&String> = b.records.keys().collect();
ks.sort();
for rn in ks {
let &(o, s) = b.records.get(rn).unwrap();
if o + 16 > by.len() {
continue;
}
let magic = &by[o..o + 4];
let h4 = u32::from_be_bytes(by[o + 4..o + 8].try_into().unwrap());
let h8 = u32::from_be_bytes(by[o + 8..o + 12].try_into().unwrap());
let maxt = ui_layout::parse_build(&by[o..o + s])
.map(|lb| {
lb.elements
.iter()
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
.max()
.unwrap_or(0)
})
.unwrap_or(0);
println!(" {rn:<24} magic={:?} +04={:08x}({:.1}) +08={h8:<6} max keyframe t={maxt} ratio={:.4}",
String::from_utf8_lossy(magic), h4, h4 as f64/65536.0,
if maxt>0 {h8 as f64/maxt as f64} else {0.0});
}
}
}

View File

@@ -29,9 +29,7 @@ fn main() {
paks.sort(); paks.sort();
for p in &paks { for p in &paks {
let Ok(arc) = PakArchive::open(p) else { let Ok(arc) = PakArchive::open(p) else { continue };
continue;
};
for (i, e) in arc.entries().iter().enumerate() { for (i, e) in arc.entries().iter().enumerate() {
let Ok(b) = arc.read(e) else { continue }; let Ok(b) = arc.read(e) else { continue };
if !find(&b, b"ACHIEVEMENTS_REQUIREMENTS") { if !find(&b, b"ACHIEVEMENTS_REQUIREMENTS") {

View File

@@ -1,41 +0,0 @@
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").unwrap());
println!("# Which elements the reference renderer now draws ADDITIVE, per screen.");
println!("# Source: T8aD +0x04 bit 0x02, docs/re/structures/ui-blend-mode-decoded.md.");
println!("# Generated after ui_layout::blit gained an additive path (2026-09-01).");
println!("# Before that change EVERY row below was drawn alpha-over by our renderer,");
println!("# which is why `verify-screen` was structurally incapable on these screens.");
for pak in ["GP_TITLE", "GP_OPTIONS"] {
let Ok(ar) = PakArchive::open(root.join(format!("dat/{pak}.pak"))) else {
continue;
};
let n = ar.entries().len();
for e in 0..n {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let mut add: Vec<&String> = b
.sprites
.keys()
.filter(|s| ui_layout::blend_additive_by_name(&b, &by, s) == Some(true))
.collect();
if add.is_empty() {
continue;
}
add.sort();
println!(
"\n{pak} entry {e} -- {} of {} sprites additive",
add.len(),
b.sprites.len()
);
for s in add {
println!(" {s}");
}
}
}
}

View File

@@ -1,61 +0,0 @@
//! Does `resolve_movie_voice_region` start LATE, and by exactly how much?
//!
//! The port agent's arithmetic: the running decoder's three `ADV` XMA contexts sum
//! to **3 584 000** payload bytes, but the resolved voice region is **3 114 352** —
//! 15 % too small to hold them. One of the two spans is not what the other thinks
//! it is, and the disc side is this crate's.
//!
//! The gap is exact. `ctx0` declares **632** packets (1 294 336 B); the leading
//! chunk the resolver yields has **394** (806 912 B). The difference is **238
//! packets = 487 424 B**, a whole number of packets — which is what a start offset
//! looks like, not corruption.
//!
//! So: walk the region start backwards and report where `to_xma_riffs` first
//! reproduces the decoder's own three sizes. The probe's byte_sizes are the
//! control — this is not free to fit, it either lands on them or it does not.
//!
//! cargo run -p sylpheed-formats --example adv_region_extend
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::{self, VoiceLang};
/// What the running decoder reported (docs/re/structures/voice-three-streams-are-concurrent.md).
const WANT: [usize; 3] = [1_294_336, 1_118_208, 1_171_456];
fn main() {
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
let (start, end) =
media::resolve_movie_voice_region(&src, "ADV", VoiceLang::English).expect("region");
println!("resolver says {start}..{end} ({} B)", end - start);
println!(
"decoder wants {:?} = {} B payload\n",
WANT,
WANT.iter().sum::<usize>()
);
for back_packets in [0usize, 100, 200, 237, 238, 239, 300, 400] {
let back = (back_packets * 2048) as u64;
if back > start {
continue;
}
let s = start - back;
let Ok(bytes) = src.read_segment_range("dat/sound", s, (end - s) as usize) else {
println!("-{back_packets:4} packets: unreadable");
continue;
};
let riffs = slb::to_xma_riffs(&bytes);
let sizes: Vec<usize> = riffs.iter().map(|r| r.len() - 60).collect();
let hit = sizes.len() == 3 && sizes.iter().zip(WANT.iter()).all(|(a, b)| a == b);
println!(
"-{back_packets:4} packets (start {s}): {} chunk(s) {:?}{}",
riffs.len(),
sizes,
if hit {
" <== MATCHES THE DECODER"
} else {
""
}
);
}
}

View File

@@ -1,44 +0,0 @@
//! Dump `ADV`'s voice chunks as RIFF/XMA, so each can be decoded and identified.
//!
//! `intro-audio-decomposed.md` measured that the intro's output is the movie's own
//! WMA Pro 5.1 track at 0.600 **plus** three streams occupying a front pair, a
//! centre (with a silent partner) and a rear pair. What it could **not** say is
//! *which* stream sits where — the assignment there is by position, not content.
//! The port needs that to weight a positional downmix.
//!
//! This writes the chunks out so they can be decoded (ffmpeg has `xma2`) and
//! correlated against the per-channel residuals.
//!
//! cargo run -p sylpheed-formats --example adv_voice_dump -- OUTDIR [MOVIE]
use sylpheed_formats::media::{self, DirectorySource, DiscSource};
use sylpheed_formats::slb::{self, VoiceLang};
fn main() {
let out = std::env::args().nth(1).expect("OUTDIR");
let movie = std::env::args().nth(2).unwrap_or_else(|| "ADV".into());
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
std::fs::create_dir_all(&out).expect("outdir");
let (start, end) =
media::resolve_movie_voice_region(&src, &movie, VoiceLang::English).expect("voice region");
let bytes = src
.read_segment_range("dat/sound", start, (end - start) as usize)
.expect("region");
println!("{movie}: region {start}..{end} = {} B", end - start);
let riffs = slb::to_xma_riffs(&bytes);
println!("{} RIFF chunk(s)", riffs.len());
for (i, r) in riffs.iter().enumerate() {
let p = format!("{out}/{movie}_{i}.xma");
std::fs::write(&p, r).expect("write");
// the probe reports `byte_size` = RIFF total - 60; print both so the
// dump can be tied to a specific XMA context by its own number
println!(
" chunk {i}: {} B byte_size-equivalent {} -> {p}",
r.len(),
r.len() as i64 - 60
);
}
}

View File

@@ -1,18 +1,9 @@
use sylpheed_formats::{game_data as gd, PakArchive}; use sylpheed_formats::{game_data as gd, PakArchive};
fn main() { fn main(){
let disc = std::env::var("SYLPHEED_DISC").unwrap(); let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap(); let pak=PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let a = gd::load_arsenal(&pak); let a=gd::load_arsenal(&pak);
for (hp, list) in [ for (hp,list) in [("NOSE",&a.nose),("ARM1",&a.arm1),("ARM2",&a.arm2),("ARM3",&a.arm3)]{
("NOSE", &a.nose), println!("{hp} ({}): {:?}", list.len(), list.iter().take(8).collect::<Vec<_>>());
("ARM1", &a.arm1),
("ARM2", &a.arm2),
("ARM3", &a.arm3),
] {
println!(
"{hp} ({}): {:?}",
list.len(),
list.iter().take(8).collect::<Vec<_>>()
);
} }
} }

View File

@@ -1,44 +0,0 @@
//! List a sound bank's streams and their declared rates.
//!
//! cargo run -p sylpheed-formats --example bank_streams -- <disc> BGM_102.slb …
use sylpheed_formats::media::{self, DirectorySource};
use sylpheed_formats::{hash::name_hash, slb};
fn main() {
let mut a = std::env::args().skip(1);
let disc = a.next().expect("usage: bank_streams <disc> NAME.slb…");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
for name in a {
let h = name_hash(&name);
match media::read_sound_bank(&src, h) {
Ok(bytes) => {
let riffs = slb::to_xma_riffs(&bytes);
println!(
"{name} (hash {h:08x}, {} B on disc) header {:?} -> {} stream(s)",
bytes.len(),
slb::bank_header_len(&bytes),
riffs.len()
);
for (i, r) in riffs.iter().enumerate() {
let rate = if r.len() >= 0x28 {
u32::from_le_bytes(r[0x20..0x24].try_into().unwrap())
} else {
0
};
let payload = r.len() - 60;
println!(
" stream {i}: payload {payload} B ({} packets) declared {rate} B/s \
=> {:.3} s",
payload / 2048,
if rate > 0 {
payload as f64 / rate as f64
} else {
0.0
}
);
}
}
Err(e) => println!("{name}: {e}"),
}
}
}

View File

@@ -1,38 +1,18 @@
use sylpheed_formats::{game_data as gd, PakArchive}; use sylpheed_formats::{game_data as gd, PakArchive};
fn main() { fn main(){
let disc = std::env::var("SYLPHEED_DISC").unwrap(); let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let units = gd::load_units(&pak); let units=gd::load_units(&pak); let vessels=gd::load_vessels(&pak);
let vessels = gd::load_vessels(&pak); let hp=|id:&str|->String{
let hp = |id: &str| -> String { units.iter().find(|u|u.id.as_deref()==Some(id)).and_then(|u|u.hp).map(|h|format!("{h:.0}hp"))
units .or_else(||vessels.iter().find(|v|v.id.as_deref()==Some(id)).and_then(|v|v.hp).map(|h|format!("{h:.0}HP")))
.iter()
.find(|u| u.id.as_deref() == Some(id))
.and_then(|u| u.hp)
.map(|h| format!("{h:.0}hp"))
.or_else(|| {
vessels
.iter()
.find(|v| v.id.as_deref() == Some(id))
.and_then(|v| v.hp)
.map(|h| format!("{h:.0}HP"))
})
.unwrap_or("·".into()) .unwrap_or("·".into())
}; };
let rosters = gd::load_unit_rosters(&pak); let rosters=gd::load_unit_rosters(&pak);
for r in rosters.iter().filter(|r| r.stage.is_some()).take(4) { for r in rosters.iter().filter(|r|r.stage.is_some()).take(4){
println!( println!("\n{}{} combatants:", r.stage.as_deref().unwrap(), r.units.len());
"\n{}{} combatants:", for u in r.units.iter().filter(|u|u.contains("ADAN")).take(6){
r.stage.as_deref().unwrap(), let short=u.trim_start_matches("UN_").split('_').skip(1).collect::<Vec<_>>().join("_");
r.units.len()
);
for u in r.units.iter().filter(|u| u.contains("ADAN")).take(6) {
let short = u
.trim_start_matches("UN_")
.split('_')
.skip(1)
.collect::<Vec<_>>()
.join("_");
println!(" {short:32} {}", hp(u)); println!(" {short:32} {}", hp(u));
} }
} }

View File

@@ -8,22 +8,13 @@ fn main() {
let bytes = std::fs::read(&a[1]).unwrap(); let bytes = std::fs::read(&a[1]).unwrap();
let names = sylpheed_formats::mesh::xbg7_resource_names(&bytes); let names = sylpheed_formats::mesh::xbg7_resource_names(&bytes);
let ids: Vec<String> = { let ids: Vec<String> = {
let mut v: Vec<String> = names let mut v: Vec<String> = names.iter().filter(|n| is_base_part(n))
.iter() .filter_map(|n| ship_id_of(n).map(|s| s.to_string())).collect();
.filter(|n| is_base_part(n)) v.sort(); v.dedup(); v.truncate(5); v
.filter_map(|n| ship_id_of(n).map(|s| s.to_string()))
.collect();
v.sort();
v.dedup();
v.truncate(5);
v
}; };
for id in &ids { for id in &ids {
let want: HashSet<String> = names let want: HashSet<String> = names.iter()
.iter() .filter(|n| ship_id_of(n) == Some(id.as_str())).cloned().collect();
.filter(|n| ship_id_of(n) == Some(id.as_str()))
.cloned()
.collect();
let t = Instant::now(); let t = Instant::now();
let got = Xbg7Model::models_named(&bytes, &want, &|| false); let got = Xbg7Model::models_named(&bytes, &want, &|| false);
println!("{id}: {} models in {:?}", got.len(), t.elapsed()); println!("{id}: {} models in {:?}", got.len(), t.elapsed());

View File

@@ -28,10 +28,7 @@ fn main() {
std::process::exit(1); std::process::exit(1);
}; };
let (vc, ic) = markers[0]; let (vc, ic) = markers[0];
println!( println!("{name}: {} marker(s), first = {vc} verts / {ic} indices, stride {stride}", markers.len());
"{name}: {} marker(s), first = {vc} verts / {ic} indices, stride {stride}",
markers.len()
);
// Where did the decoder put it? // Where did the decoder put it?
let ours = Xbg7Model::stage_models(&bytes) let ours = Xbg7Model::stage_models(&bytes)
@@ -41,10 +38,7 @@ fn main() {
println!("our anchor: {ours:?}"); println!("our anchor: {ours:?}");
let starts = debug_vertex_run_starts(&bytes, stride); let starts = debug_vertex_run_starts(&bytes, stride);
println!( println!("{} candidate vertex-run starts for stride {stride}", starts.len());
"{} candidate vertex-run starts for stride {stride}",
starts.len()
);
// Score every (start, pad): degenerate triangles, winding against the stored // Score every (start, pad): degenerate triangles, winding against the stored
// normals, and whether the run covers the pool exactly. // normals, and whether the run covers the pool exactly.
@@ -73,15 +67,12 @@ fn main() {
] ]
}) })
.collect(); .collect();
if pos if pos.iter().any(|p| p.iter().any(|c| !c.is_finite() || c.abs() > 1e6)) {
.iter()
.any(|p| p.iter().any(|c| !c.is_finite() || c.abs() > 1e6))
{
continue; continue;
} }
let mut degen = 0usize; let mut degen = 0usize;
let (mut agree, mut counted) = (0usize, 0usize); let (mut agree, mut counted) = (0usize, 0usize);
for t in idx.as_chunks::<3>().0 { for t in idx.chunks_exact(3) {
let (x, y, z) = (t[0] as usize, t[1] as usize, t[2] as usize); let (x, y, z) = (t[0] as usize, t[1] as usize, t[2] as usize);
if x == y || y == z || x == z { if x == y || y == z || x == z {
degen += 1; degen += 1;
@@ -91,16 +82,8 @@ fn main() {
// stand-in so this stays declaration-agnostic: a consistent mesh // stand-in so this stays declaration-agnostic: a consistent mesh
// has all faces pointing away from the centroid on a convex-ish // has all faces pointing away from the centroid on a convex-ish
// hull. Weak, so degeneracy leads the sort. // hull. Weak, so degeneracy leads the sort.
let e1 = [ let e1 = [pos[y][0] - pos[x][0], pos[y][1] - pos[x][1], pos[y][2] - pos[x][2]];
pos[y][0] - pos[x][0], let e2 = [pos[z][0] - pos[x][0], pos[z][1] - pos[x][1], pos[z][2] - pos[x][2]];
pos[y][1] - pos[x][1],
pos[y][2] - pos[x][2],
];
let e2 = [
pos[z][0] - pos[x][0],
pos[z][1] - pos[x][1],
pos[z][2] - pos[x][2],
];
let f = [ let f = [
e1[1] * e2[2] - e1[2] * e2[1], e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2], e1[2] * e2[0] - e1[0] * e2[2],
@@ -121,19 +104,12 @@ fn main() {
agree += 1; agree += 1;
} }
} }
let w = if counted == 0 { let w = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
0.0
} else {
agree as f32 / counted as f32
};
rows.push((degen, w.max(1.0 - w), vb, pad, max_idx, max_idx + 1 == vc)); rows.push((degen, w.max(1.0 - w), vb, pad, max_idx, max_idx + 1 == vc));
} }
} }
rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.total_cmp(&a.1))); rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.total_cmp(&a.1)));
println!( println!("\n{} in-range candidates; best 12 by (degenerate, winding):", rows.len());
"\n{} in-range candidates; best 12 by (degenerate, winding):",
rows.len()
);
for (d, w, vb, pad, mx, cov) in rows.iter().take(12) { for (d, w, vb, pad, mx, cov) in rows.iter().take(12) {
let mark = if Some(*vb) == ours { " <-- ours" } else { "" }; let mark = if Some(*vb) == ours { " <-- ours" } else { "" };
println!( println!(

View File

@@ -1,28 +0,0 @@
//! Dump one BGM bank's waves as RIFF/XMA so they can be decoded and compared
//! against a capture of the running game.
//!
//! cargo run -p sylpheed-formats --example bgm_dump -- BGM_103.slb OUTDIR
use sylpheed_formats::media::{self, DirectorySource};
use sylpheed_formats::slb;
fn main() {
let name = std::env::args()
.nth(1)
.unwrap_or_else(|| "BGM_103.slb".into());
let out = std::env::args().nth(2).expect("OUTDIR");
let disc = std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
std::fs::create_dir_all(&out).expect("outdir");
let h = sylpheed_formats::hash::name_hash(&name);
let bytes = media::read_sound_bank(&src, h).expect("bank");
println!("{name}: {} B", bytes.len());
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
let p = format!("{out}/{}_{i}.xma", name.trim_end_matches(".slb"));
std::fs::write(&p, r).expect("write");
println!(
" wave {i}: {} B (byte_size {}) -> {p}",
r.len(),
r.len() - 60
);
}
}

View File

@@ -1,106 +0,0 @@
//! Does a screen declare its own OPAQUE BLACK backdrop? Disc-wide.
//!
//! `sylpheed-port` observed that the splash builds declare `palogo_eff0.prm` as a
//! full-screen primitive at t=0 with `fade_argb 0xff000000` -- alpha 255 over RGB
//! 000000 -- and turned it into a candidate predicate: a declared opaque-black
//! backdrop separates STANDALONE screens from COMPOSITED ones. On their sixteen
//! exported screens it splits 12 / 4, with all four exceptions independently known
//! to be composited (the two `press_start` plates, and two loading builds that
//! carry the `pgloading_*` set without its backdrop).
//!
//! That matters because the corpus previously told them "no content rule exists,
//! take the entry index" -- correct for the question asked (recognise the splash),
//! but this is a content rule for a different and useful question. They asked for
//! it to be tested against an archive they do not have. This is that test.
//!
//! CONTROL: it must reproduce the 12/4 split on GP_TITLE's sixteen composable
//! bundles before its disc-wide numbers mean anything.
//!
//! cargo run -p sylpheed-formats --example black_backdrop_predicate
use std::io::Write;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
/// A screen declares its own backdrop if some `.prm` primitive holds
/// `fade == 0xff000000` at t = 0: full alpha over black.
fn has_black_backdrop(b: &ui_layout::UiBuild) -> Option<String> {
for el in &b.elements {
if !el.name.ends_with(".prm") {
continue;
}
if let Some(k) = el.keyframes.iter().find(|k| k.time == Some(0)) {
if k.fade == 0xff00_0000 {
return Some(el.name.clone());
}
}
}
None
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
println!("== CONTROL: GP_TITLE's 16 composable bundles (port reports 12 with, 4 without)");
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let (mut y, mut n) = (0, 0);
for e in 0..16usize {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
match has_black_backdrop(&b) {
Some(nm) => {
y += 1;
println!(" entry {e:>2} YES {nm}")
}
None => {
n += 1;
println!(" entry {e:>2} no")
}
}
}
println!(" -> {y} with, {n} without\n");
println!("== DISC-WIDE, over every screen build");
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
let (mut tot, mut with) = (0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (mut t, mut w) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
t += 1;
if has_black_backdrop(&b).is_some() {
w += 1
}
}
if t > 0 {
println!("{name:30} {w:4} / {t:<4} declare a black backdrop");
std::io::stdout().flush().ok();
}
tot += t;
with += w;
}
println!(
"\n{with} of {tot} screen builds disc-wide declare an opaque-black backdrop \
({:.1} %)",
100.0 * with as f64 / tot as f64
);
println!("--- END ---");
}

View File

@@ -1,107 +0,0 @@
//! Control for the new public accessor `ui_layout::sprite_blend_additive`.
//!
//! `blend_vs_t8ad_bit` established the field by reading the `T8aD` header inline.
//! The exporter cannot do that — `Element` exposed nothing at `+0x04`, which is
//! why a blend map keyed by SCREEN NAME had to be authored, and why the Japanese
//! menus were being asserted-by-omission to blend differently from the English
//! ones. This checks the accessor the exporter will actually call, against the
//! same 35 oracle rows, so a later refactor cannot silently change the field.
//!
//! cargo run -p sylpheed-formats --example blend_api_check
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
/// (build entry, sprite, measured additive?) — from `data/blend-bit-vs-oracle.txt`,
/// every label an `RB_BLENDCONTROL0` value read out of the guest command stream.
const MEASURED: &[(usize, &str, bool)] = &[
(4, "ptbase2.t32", false),
(4, "ptlogo1.t32", false),
(4, "ptlogo2.t32", false),
(4, "ptlogo_tm.t32", false),
(4, "ptcopyright.t32", false),
(4, "ptlogo_back2.t32", false),
(4, "ptlogo_back2eff.t32", false),
(2, "ptbtn00.t32", false),
(2, "ptbtn00f.t32", true),
(5, "ptbase.t32", false),
(5, "ptmsg.t32", false),
(5, "ptbtn01f.t32", false),
(5, "ptbtneff01.t32", false),
(5, "pteff10.t32", true),
(5, "pteff12.t32", true),
(6, "pteff21.t32", true),
(6, "pteff22.t32", true),
(6, "pteff23.t32", true),
(6, "ptframe3.t32", true),
(6, "ptframe4.t32", true),
(6, "pteff03.t32", true),
(6, "pteff03a.t32", true),
];
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let (mut ok, mut bad, mut missing) = (0, 0, 0);
println!(
"{:<7} {:<22} {:<10} {:<10} ",
"entry", "sprite", "expected", "accessor"
);
for e in [2usize, 4, 5, 6] {
let by = ar.read(&ar.entries()[e]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for &(oe, name, additive) in MEASURED {
if oe != e {
continue;
}
// Prefer the Element accessor; fall back to the by-name one for
// focused variants, which are reached through `opt ` and carry no
// top-level element of their own.
let got = b
.elements
.iter()
.find(|x| x.sprite.as_deref() == Some(name))
.and_then(|el| ui_layout::sprite_blend_additive(&b, &by, el))
.or_else(|| ui_layout::blend_additive_by_name(&b, &by, name));
if got.is_none() {
println!("{e:<7} {name:<22} {additive:<10} {:<10} MISSING", "-");
missing += 1;
continue;
}
match got {
Some(g) if g == additive => {
ok += 1;
println!("{e:<7} {name:<22} {additive:<10} {g:<10} OK");
}
other => {
bad += 1;
println!("{e:<7} {name:<22} {additive:<10} {other:?} MISMATCH");
}
}
}
}
println!(
"\n{ok} agree, {bad} mismatched, {missing} not found (of {})",
MEASURED.len()
);
// The control that removes the test's own subject: the accessor must also
// report a MIX. An accessor stuck at one value would pass every `false` row.
let by = ar.read(&ar.entries()[6]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
let add = b
.elements
.iter()
.filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(true))
.count();
let over = b
.elements
.iter()
.filter(|e| ui_layout::sprite_blend_additive(&b, &by, e) == Some(false))
.count();
println!("control -- entry 6 must report BOTH values: additive={add} alpha-over={over}");
assert!(add > 0 && over > 0, "accessor is not discriminating");
assert_eq!(
bad, 0,
"the public accessor disagrees with the committed oracle"
);
println!("PASS");
}

View File

@@ -1,61 +0,0 @@
//! A PREDICTION, written before the capture that tests it.
//!
//! `blend_vs_t8ad_bit` finds that `T8aD +0x04` bit `0x02` separates additive from
//! alpha-over on all 35 elements whose blend has been measured off the GPU, and
//! that no other bit of the 48-byte header does. That is a fit to three screens.
//!
//! The developer splash (`GP_TITLE` entries 10 and 13) has **never been captured**
//! and is one of the five screens the port ships. This prints what the bit says
//! its elements should be, so the capture can falsify it rather than confirm it.
//!
//! Takes an archive and a build list, so the prediction can be written for any
//! screen -- including one in a DIFFERENT pak, which is the sharper test: the
//! splash predicts alpha-over for both its elements and so can only fail, never
//! discriminate, while a screen with a predicted MIX can do both.
//!
//! cargo run -p sylpheed-formats --example blend_prediction_splash
//! cargo run -p sylpheed-formats --example blend_prediction_splash -- GP_OPTIONS 0 1 2
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let args: Vec<String> = std::env::args().skip(1).collect();
let pak = args
.iter()
.find(|a| a.parse::<usize>().is_err())
.cloned()
.unwrap_or_else(|| "GP_TITLE".to_string());
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
let builds: Vec<usize> = args.iter().filter_map(|a| a.parse().ok()).collect();
let builds = if builds.is_empty() {
(0..ar.entries().len()).collect()
} else {
builds
};
for e in builds {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
println!("=== {pak} entry {e} ===");
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
let (off, size) = b.sprites[n];
let s = &by[off..(off + size).min(by.len())];
if s.len() < 8 || &s[0..4] != b"T8aD" {
continue;
}
let w = u32::from_be_bytes([s[4], s[5], s[6], s[7]]);
println!(
"{n:<26} +0x04 = {w:08X} bit 0x02 {} PREDICT {}",
if w & 2 != 0 { "SET " } else { "clear" },
if w & 2 != 0 { "ADDITIVE" } else { "alpha-over" }
);
}
println!();
}
}

View File

@@ -1,298 +0,0 @@
//! Does `T8aD +0x04` bit `0x02` predict the blend the GAME uses?
//!
//! ⚠️ **`REFUTED.md` kills this claim**: *"`T8aD +0x04` bit `0x02` selects an
//! additive blend" → mine, and refuted. Blending those sprites additively
//! worsens every measure against the capture.* That refutation rests entirely on
//! **our renderer** — it is a claim about our renderer, and the corpus's own rule
//! says so. Since it was written, the blend has been measured off the GPU per
//! draw on three screens (`structures/ui-blend-mode-measured.md`), so the claim
//! can now be tested against the oracle instead of against a render.
//!
//! The labels below are **not** from a render. Every one is a
//! `RB_BLENDCONTROL0` value read out of the guest command stream and attributed
//! to an element by quad size:
//! `data/ui-blend-mode-measured.txt`, `data/ui-blend-title-and-replication.txt`,
//! `data/ui-blend-extras-complete.txt`.
//!
//! cargo run -p sylpheed-formats --example blend_vs_t8ad_bit
use std::collections::BTreeMap;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
/// (build entry, sprite, measured additive?) — the oracle's verdicts, verbatim.
const MEASURED: &[(usize, &str, bool)] = &[
// --- GP_TITLE entry 4 + 2, the live title -------------------------------
(4, "ptbase2.t32", false),
(4, "ptlogo1.t32", false),
(4, "ptlogo2.t32", false),
(4, "ptlogo_tm.t32", false),
(4, "ptcopyright.t32", false),
(4, "ptlogo_back2.t32", false),
(4, "ptlogo_back2eff.t32", false),
(2, "ptbtn00.t32", false),
(2, "ptbtn00f.t32", true),
// --- entry 5, the main menu ---------------------------------------------
(5, "ptbase.t32", false),
(5, "ptmsg.t32", false),
(5, "ptbtn01f.t32", false),
(5, "ptbtneff01.t32", false),
(5, "pteff10.t32", true),
(5, "pteff12.t32", true),
(5, "ptframe1.t32", true),
(5, "ptframe2.t32", true),
(5, "pteff03.t32", true), // the rotated sweep strips, via ptloop01/02
(5, "pteff03a.t32", true),
// --- entry 6, EXTRAS ------------------------------------------------------
(6, "ptbase.t32", false),
(6, "ptmsg2.t32", false),
(6, "pttitle.t32", false),
(6, "ptbtn11f.t32", false),
(6, "ptbtn12.t32", false),
(6, "ptbtn13.t32", false),
(6, "ptbtneff02.t32", false),
(6, "pteff10.t32", true),
(6, "pteff20.t32", true),
(6, "pteff21.t32", true),
(6, "pteff22.t32", true),
(6, "pteff23.t32", true),
(6, "ptframe3.t32", true),
(6, "ptframe4.t32", true),
(6, "pteff03.t32", true),
(6, "pteff03a.t32", true),
];
fn main() {
if std::env::args().any(|a| a == "decl") {
decl_rivals();
return;
}
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let mut hdr: BTreeMap<(usize, String), u32> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let by = ar.read(&ar.entries()[e]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for (n, &(off, size)) in &b.sprites {
let s = &by[off..(off + size).min(by.len())];
if s.len() < 8 || &s[0..4] != b"T8aD" {
continue;
}
hdr.insert((e, n.clone()), u32::from_be_bytes([s[4], s[5], s[6], s[7]]));
}
}
println!(
"{:<10} {:<22} {:<10} {:>10} measured blend",
"entry", "sprite", "+0x04", "bit 0x02"
);
let (mut tp, mut tn, mut fp, mut fnn, mut missing) = (0, 0, 0, 0, 0);
for &(e, n, additive) in MEASURED {
let Some(&w) = hdr.get(&(e, n.to_string())) else {
println!(
"{e:<10} {n:<22} {:<10} {:>10} {}",
"MISSING",
"-",
if additive { "ADDITIVE" } else { "alpha-over" }
);
missing += 1;
continue;
};
let bit = w & 0x02 != 0;
match (bit, additive) {
(true, true) => tp += 1,
(false, false) => tn += 1,
(true, false) => fp += 1,
(false, true) => fnn += 1,
}
println!(
"{e:<10} {n:<22} {:08X} {:>10} {}{}",
w,
bit,
if additive { "ADDITIVE" } else { "alpha-over" },
if bit == additive {
""
} else {
" <== DISAGREES"
}
);
}
println!("\nbit set & additive {tp}");
println!("bit clear & alpha-over {tn}");
println!("bit set & alpha-over {fp} <- false positives");
println!("bit clear & additive {fnn} <- false negatives");
println!("sprite not found {missing}");
println!(
"\n{}",
if fp == 0 && fnn == 0 && missing == 0 {
"PERFECT PARTITION on every element whose blend was measured."
} else {
"THE BIT DOES NOT PREDICT THE MEASURED BLEND."
}
);
// ── THE CONTROL THAT MATTERS ────────────────────────────────────────────
// A perfect partition is worthless if half the header partitions equally
// well: then the sample is too small to single out a field, and picking
// `+0x04` bit 0x02 out of the tie is the same mistake as picking `+0x08`
// 0x8050 was. So: how many OTHER bits of the first 12 header words separate
// the same 35 elements without error?
let mut rivals: Vec<String> = Vec::new();
let mut words: BTreeMap<(usize, String), Vec<u32>> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let by = ar.read(&ar.entries()[e]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for (n, &(off, size)) in &b.sprites {
let s = &by[off..(off + size).min(by.len())];
if s.len() < 48 || &s[0..4] != b"T8aD" {
continue;
}
words.insert(
(e, n.clone()),
(0..12)
.map(|k| {
u32::from_be_bytes([s[k * 4], s[k * 4 + 1], s[k * 4 + 2], s[k * 4 + 3]])
})
.collect(),
);
}
}
for w in 0..12 {
for bit in 0..32 {
let mut ok = true;
let mut set_seen = false;
let mut clear_seen = false;
for &(e, n, additive) in MEASURED {
let Some(v) = words.get(&(e, n.to_string())) else {
ok = false;
break;
};
let on = (v[w] >> bit) & 1 == 1;
if on {
set_seen = true
} else {
clear_seen = true
}
if on != additive {
ok = false;
break;
}
}
// A constant bit trivially "agrees" with nothing; require both sides.
if ok && set_seen && clear_seen {
rivals.push(format!("+0x{:02X} bit {bit} (0x{:X})", w * 4, 1u32 << bit));
}
}
}
println!("\nRIVAL FIELDS — other bits of the first 12 header words that separate");
println!("the same 35 elements with zero errors: {}", rivals.len());
for r in &rivals {
println!(" {r}");
}
if rivals.len() == 1 {
println!(" -> the sample singles out ONE field. Nothing else in the header does it.");
} else {
println!(
" -> the sample does NOT single out a field; {} candidates tie.",
rivals.len()
);
}
}
// ── An integrity check the published decode did NOT do ──────────────────────
// The rival sweep above covers the 48-byte T8aD header. It does NOT cover the
// 60-byte DECLARATION entry, and the earlier declaration hunt was run with
// labels taken from the port's RENDER -- which put pteff10, pteff12, pteff20 and
// pteff21..23 on the alpha-over side, where the oracle says all six are
// additive. So the declaration has never been swept with correct labels, and if
// one of its words also partitions the 35 without error, "the field is the T8aD
// bit" is underdetermined.
//
// Run as: cargo run -p sylpheed-formats --example blend_vs_t8ad_bit -- decl
#[allow(dead_code)]
fn decl_rivals() {
use std::collections::BTreeMap;
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
const AT: usize = 0x20;
const STRIDE: usize = 60;
let mut decl: BTreeMap<(usize, String), Vec<u32>> = BTreeMap::new();
for e in [2usize, 4, 5, 6] {
let Ok(by) = ar.read(&ar.entries()[e]) else {
continue;
};
if by.len() < 0x18 {
continue;
}
let count = u32::from_be_bytes([by[0x14], by[0x15], by[0x16], by[0x17]]) as usize;
for i in 0..count {
let at = AT + i * STRIDE;
if at + STRIDE > by.len() {
break;
}
let end = by[at..at + 12].iter().position(|&c| c == 0).unwrap_or(12);
let name = String::from_utf8_lossy(&by[at..at + end]).to_string();
decl.insert(
(e, name),
(0..15)
.map(|k| {
u32::from_be_bytes([
by[at + k * 4],
by[at + k * 4 + 1],
by[at + k * 4 + 2],
by[at + k * 4 + 3],
])
})
.collect(),
);
}
}
let mut missing: Vec<String> = Vec::new();
for &(e, n, _) in MEASURED {
if !decl.contains_key(&(e, n.to_string())) {
missing.push(format!("entry {e} {n}"));
}
}
println!("\n=== DECLARATION-ENTRY RIVAL SWEEP ===");
println!(
"measured elements with NO declaration entry of their own: {} of {}",
missing.len(),
MEASURED.len()
);
for m in &missing {
println!(" {m}");
}
if !missing.is_empty() {
println!(" -> no declaration field can select the blend for these, because they");
println!(" have no declaration entry. The header is the only per-sprite home.");
}
let labelled: Vec<&(usize, &str, bool)> = MEASURED
.iter()
.filter(|(e, n, _)| decl.contains_key(&(*e, n.to_string())))
.collect();
let mut rivals = 0;
#[allow(clippy::needless_range_loop)]
for w in 0..15 {
for bit in 0..32 {
let (mut ok, mut s, mut c) = (true, false, false);
for &&(e, n, additive) in &labelled {
let on = (decl[&(e, n.to_string())][w] >> bit) & 1 == 1;
if on {
s = true
} else {
c = true
}
if on != additive {
ok = false;
break;
}
}
if ok && s && c {
println!(" RIVAL: declaration +0x{:02X} bit {bit}", w * 4);
rivals += 1;
}
}
}
println!(
"declaration bits that separate the {} labellable elements: {rivals}",
labelled.len()
);
}

View File

@@ -7,8 +7,8 @@
//! those float values. //! those float values.
//! //!
//! Usage: bounds_in_descriptor <container.xpr> <resource>... //! Usage: bounds_in_descriptor <container.xpr> <resource>...
use std::collections::HashSet;
use sylpheed_formats::mesh::{xbg7_descriptor_range, Xbg7Model}; use sylpheed_formats::mesh::{xbg7_descriptor_range, Xbg7Model};
use std::collections::HashSet;
fn main() { fn main() {
let a: Vec<String> = std::env::args().collect(); let a: Vec<String> = std::env::args().collect();
@@ -24,9 +24,7 @@ fn main() {
} }
} }
} }
let Some((d0, d1)) = xbg7_descriptor_range(&bytes, &m.name) else { let Some((d0, d1)) = xbg7_descriptor_range(&bytes, &m.name) else { continue };
continue;
};
println!( println!(
"{} descriptor 0x{d0:x}..0x{d1:x} ({} bytes), box lo{:?} hi{:?}", "{} descriptor 0x{d0:x}..0x{d1:x} ({} bytes), box lo{:?} hi{:?}",
m.name, m.name,
@@ -36,12 +34,8 @@ fn main() {
); );
// Where in the descriptor does each bound value appear (±0.01)? // Where in the descriptor does each bound value appear (±0.01)?
let targets: Vec<(&str, f32)> = vec![ let targets: Vec<(&str, f32)> = vec![
("lo.x", lo[0]), ("lo.x", lo[0]), ("lo.y", lo[1]), ("lo.z", lo[2]),
("lo.y", lo[1]), ("hi.x", hi[0]), ("hi.y", hi[1]), ("hi.z", hi[2]),
("lo.z", lo[2]),
("hi.x", hi[0]),
("hi.y", hi[1]),
("hi.z", hi[2]),
]; ];
for (label, v) in targets { for (label, v) in targets {
let mut at: Vec<usize> = Vec::new(); let mut at: Vec<usize> = Vec::new();
@@ -53,10 +47,7 @@ fn main() {
} }
o += 4; o += 4;
} }
println!( println!(" {label:5} {v:10.3} at descriptor offsets {:x?}", &at[..at.len().min(6)]);
" {label:5} {v:10.3} at descriptor offsets {:x?}",
&at[..at.len().min(6)]
);
} }
} }
} }

View File

@@ -1,39 +1,24 @@
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive}; use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
fn main() { fn main(){
let disc = std::env::var("SYLPHEED_DISC").unwrap(); let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text = TextIndex::build(&pak); let text=TextIndex::build(&pak);
let mut stages = game_data::load_stages(&pak); let mut stages=game_data::load_stages(&pak);
stages.retain(|s| { stages.retain(|s|s.id.starts_with('S') && s.id.len()==3 && s.id[1..].parse::<u32>().map(|n|n<=16).unwrap_or(false));
s.id.starts_with('S') stages.sort_by(|a,b|a.id.cmp(&b.id));
&& s.id.len() == 3
&& s.id[1..].parse::<u32>().map(|n| n <= 16).unwrap_or(false)
});
stages.sort_by(|a, b| a.id.cmp(&b.id));
println!("═══ CAMPAIGN (S01S16) ═══"); println!("═══ CAMPAIGN (S01S16) ═══");
for s in &stages { for s in &stages{
let obj = text.objectives(&s.id, 1); let obj=text.objectives(&s.id,1);
println!("\n{} · {}", s.id, s.location.as_deref().unwrap_or("?")); println!("\n{} · {}", s.id, s.location.as_deref().unwrap_or("?"));
for o in obj.iter().take(2) { for o in obj.iter().take(2){ println!("{o}"); }
println!("{o}");
}
} }
// roster with real names // roster with real names
let mut chars = game_data::load_characters(&pak); let mut chars=game_data::load_characters(&pak);
chars.retain(|c| { chars.retain(|c|c.faction.as_deref()==Some("TCAF") && c.unique==Some(true) && c.faces.len()>=4);
c.faction.as_deref() == Some("TCAF") && c.unique == Some(true) && c.faces.len() >= 4
});
println!("\n═══ PRINCIPAL CAST (TCAF, named) ═══"); println!("\n═══ PRINCIPAL CAST (TCAF, named) ═══");
for c in &chars { for c in &chars{
let id = c.id.as_deref().unwrap_or(""); let id=c.id.as_deref().unwrap_or("");
let name = text let name=text.character_name(id.trim_start_matches("Character")).or_else(||c.name_key.as_deref().and_then(|k|text.get(k))).unwrap_or("?");
.character_name(id.trim_start_matches("Character")) println!(" {name:12} ({} portraits) [{}]", c.faces.len(), id.trim_start_matches("Character"));
.or_else(|| c.name_key.as_deref().and_then(|k| text.get(k)))
.unwrap_or("?");
println!(
" {name:12} ({} portraits) [{}]",
c.faces.len(),
id.trim_start_matches("Character")
);
} }
} }

View File

@@ -17,9 +17,9 @@
//! //!
//! Usage: //! Usage:
//! cargo run --release --example capture_ib_truth -- <Stage_SNN.xpr> <capture.log>... //! cargo run --release --example capture_ib_truth -- <Stage_SNN.xpr> <capture.log>...
use std::collections::HashMap;
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model}; use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw}; use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
use std::collections::HashMap;
fn q(v: f32) -> i64 { fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64 (v as f64 * 1e4).round() as i64
@@ -60,13 +60,7 @@ fn main() {
let mut o = 0usize; let mut o = 0usize;
while o + 12 <= bytes.len() { while o + 12 <= bytes.len() {
let (x, y, z) = (be(o), be(o + 4), be(o + 8)); let (x, y, z) = (be(o), be(o + 4), be(o + 8));
if x.is_finite() if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6 {
&& y.is_finite()
&& z.is_finite()
&& x.abs() < 1e6
&& y.abs() < 1e6
&& z.abs() < 1e6
{
index.entry((q(x), q(y), q(z))).or_default().push(o as u32); index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
} }
o += 4; o += 4;
@@ -78,9 +72,7 @@ fn main() {
for dx in -1..=1i64 { for dx in -1..=1i64 {
for dy in -1..=1i64 { for dy in -1..=1i64 {
for dz in -1..=1i64 { for dz in -1..=1i64 {
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else { let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else { continue };
continue;
};
for &off in cands { for &off in cands {
for stride in (12..=64).step_by(4) { for stride in (12..=64).step_by(4) {
let ok = (1..4).all(|j| { let ok = (1..4).all(|j| {
@@ -104,9 +96,7 @@ fn main() {
eprintln!("no draw could be placed in this container"); eprintln!("no draw could be placed in this container");
std::process::exit(1); std::process::exit(1);
}; };
println!( println!("container load constant: vbase - file_offset = 0x{base_delta:X} ({n} buffers agree)");
"container load constant: vbase - file_offset = 0x{base_delta:X} ({n} buffers agree)"
);
// ── Our decoder's view of the same container. // ── Our decoder's view of the same container.
let models = Xbg7Model::stage_models(&bytes); let models = Xbg7Model::stage_models(&bytes);
@@ -114,19 +104,14 @@ fn main() {
for m in &models { for m in &models {
for sm in &m.meshes { for sm in &m.meshes {
if let Some(off) = sm.vbuf_offset { if let Some(off) = sm.vbuf_offset {
by_off.entry(off).or_default().push(( by_off
m.name.clone(), .entry(off)
sm.positions.len(), .or_default()
sm.indices.len(), .push((m.name.clone(), sm.positions.len(), sm.indices.len()));
));
} }
} }
} }
println!( println!("decoded {} resources, {} distinct vertex offsets\n", models.len(), by_off.len());
"decoded {} resources, {} distinct vertex offsets\n",
models.len(),
by_off.len()
);
// Declared-but-not-decoded resources, indexed by their first marker's // Declared-but-not-decoded resources, indexed by their first marker's
// (vertex, index) counts. A drawn buffer our decoder cannot name is the one // (vertex, index) counts. A drawn buffer our decoder cannot name is the one
@@ -148,21 +133,13 @@ fn main() {
} }
// ── The report: one row per drawn BUFFER, aggregating its index batches. // ── The report: one row per drawn BUFFER, aggregating its index batches.
let mut per_buf: HashMap< let mut per_buf: HashMap<u32, (usize, Vec<sylpheed_formats::ship_capture::CapturedIndexBuffer>, u32)> =
u32, HashMap::new();
(
usize,
Vec<sylpheed_formats::ship_capture::CapturedIndexBuffer>,
u32,
),
> = HashMap::new();
for (delta, voff, d) in &hits { for (delta, voff, d) in &hits {
if *delta != base_delta { if *delta != base_delta {
continue; continue;
} }
let e = per_buf let e = per_buf.entry(d.vbase).or_insert((*voff, Vec::new(), d.vcount));
.entry(d.vbase)
.or_insert((*voff, Vec::new(), d.vcount));
let ib = d.ib.unwrap(); let ib = d.ib.unwrap();
if !e.1.contains(&ib) { if !e.1.contains(&ib) {
e.1.push(ib); e.1.push(ib);
@@ -170,10 +147,9 @@ fn main() {
} }
let (mut pad0, mut pad_small, mut pad_off, mut unnamed) = (0usize, 0usize, 0usize, 0usize); let (mut pad0, mut pad_small, mut pad_off, mut unnamed) = (0usize, 0usize, 0usize, 0usize);
let (mut cover_exact, mut cover_short, mut idx_equal, mut idx_partial) = let (mut cover_exact, mut cover_short, mut idx_equal, mut idx_partial) = (0usize, 0usize, 0usize, 0usize);
(0usize, 0usize, 0usize, 0usize);
let mut rows: Vec<(usize, String)> = Vec::new(); let mut rows: Vec<(usize, String)> = Vec::new();
for (voff, ibs, vcount) in per_buf.values() { for (_, (voff, ibs, vcount)) in per_buf.iter() {
let batches = ibs.len(); let batches = ibs.len();
let total: u32 = ibs.iter().map(|i| i.icount).sum(); let total: u32 = ibs.iter().map(|i| i.icount).sum();
let lo = ibs.iter().map(|i| i.ibase).min().unwrap() as i64 - base_delta; let lo = ibs.iter().map(|i| i.ibase).min().unwrap() as i64 - base_delta;
@@ -181,11 +157,8 @@ fn main() {
let umax = ibs.iter().map(|i| i.imax).max().unwrap(); let umax = ibs.iter().map(|i| i.imax).max().unwrap();
let gap = *voff as i64 - hi; // bytes from the end of the index data to the vertex buffer let gap = *voff as i64 - hi; // bytes from the end of the index data to the vertex buffer
let names = by_off.get(voff); let names = by_off.get(voff);
let dec_idx = names.and_then(|v| { let dec_idx = names
v.iter() .and_then(|v| v.iter().find(|(_, p, _)| *p as u32 == *vcount).map(|(_, _, i)| *i as u32));
.find(|(_, p, _)| *p as u32 == *vcount)
.map(|(_, _, i)| *i as u32)
});
// The decoder's assumption, scored: it expects the whole index buffer at // The decoder's assumption, scored: it expects the whole index buffer at
// `vb - 2*idx_count - pad`, pad ≤ 3. // `vb - 2*idx_count - pad`, pad ≤ 3.
let dec_pad = dec_idx.map(|i| *voff as i64 - (i as i64) * 2 - lo); let dec_pad = dec_idx.map(|i| *voff as i64 - (i as i64) * 2 - lo);
@@ -245,7 +218,5 @@ fn main() {
println!( println!(
"index extent: our idx_count == sum of captured batches for {idx_equal} buffers, differs for {idx_partial}" "index extent: our idx_count == sum of captured batches for {idx_equal} buffers, differs for {idx_partial}"
); );
println!( println!("vertex-pool coverage by the union of batches: exact {cover_exact} · short {cover_short}");
"vertex-pool coverage by the union of batches: exact {cover_exact} · short {cover_short}"
);
} }

View File

@@ -12,9 +12,9 @@
//! //!
//! Usage: //! Usage:
//! cargo run --release --example capture_index_bytes -- <Stage_SNN.xpr> <capture.log>... //! cargo run --release --example capture_index_bytes -- <Stage_SNN.xpr> <capture.log>...
use std::collections::HashMap;
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw}; use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
use std::collections::HashMap;
fn q(v: f32) -> i64 { fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64 (v as f64 * 1e4).round() as i64
@@ -34,10 +34,7 @@ fn main() {
let text = std::fs::read_to_string(log).expect("log"); let text = std::fs::read_to_string(log).expect("log");
for d in parse_capture(&text) { for d in parse_capture(&text) {
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0)); let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
if d.ib.is_some_and(|i| i.head_len > 0) if d.ib.map_or(false, |i| i.head_len > 0) && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
&& d.pos.len() >= 4
&& seen.insert((log.clone(), d.vbase, k))
{
draws.push(d); draws.push(d);
} }
} }
@@ -51,13 +48,7 @@ fn main() {
let mut o = 0usize; let mut o = 0usize;
while o + 12 <= bytes.len() { while o + 12 <= bytes.len() {
let (x, y, z) = (be(o), be(o + 4), be(o + 8)); let (x, y, z) = (be(o), be(o + 4), be(o + 8));
if x.is_finite() if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6 {
&& y.is_finite()
&& z.is_finite()
&& x.abs() < 1e6
&& y.abs() < 1e6
&& z.abs() < 1e6
{
index.entry((q(x), q(y), q(z))).or_default().push(o as u32); index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
} }
o += 4; o += 4;
@@ -69,9 +60,7 @@ fn main() {
for dx in -1..=1i64 { for dx in -1..=1i64 {
for dy in -1..=1i64 { for dy in -1..=1i64 {
for dz in -1..=1i64 { for dz in -1..=1i64 {
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else { let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else { continue };
continue;
};
for &off in cands { for &off in cands {
for stride in (12..=64).step_by(4) { for stride in (12..=64).step_by(4) {
let ok = (1..4).all(|j| { let ok = (1..4).all(|j| {
@@ -103,11 +92,10 @@ fn main() {
for m in &models { for m in &models {
for sm in &m.meshes { for sm in &m.meshes {
if let Some(off) = sm.vbuf_offset { if let Some(off) = sm.vbuf_offset {
by_off.entry(off).or_default().push(( by_off
m.name.clone(), .entry(off)
sm.positions.len(), .or_default()
sm.indices.clone(), .push((m.name.clone(), sm.positions.len(), sm.indices.clone()));
));
} }
} }
} }
@@ -124,10 +112,7 @@ fn main() {
continue; continue;
} }
let ibase = d.ib.unwrap().ibase as i64; let ibase = d.ib.unwrap().ibase as i64;
ib_start ib_start.entry(d.vbase).and_modify(|e| *e = (*e).min(ibase)).or_insert(ibase);
.entry(d.vbase)
.and_modify(|e| *e = (*e).min(ibase))
.or_insert(ibase);
} }
let (mut agree, mut disagree, mut unmatched, mut nooverlap) = (0usize, 0usize, 0usize, 0usize); let (mut agree, mut disagree, mut unmatched, mut nooverlap) = (0usize, 0usize, 0usize, 0usize);

View File

@@ -1,37 +1,21 @@
//! Identify which stage + ship a capture log came from: match the capture's //! Identify which stage + ship a capture log came from: match the capture's
//! draw vertex counts against every resource (incl. LODs) of every Stage_SNN.xpr. //! draw vertex counts against every resource (incl. LODs) of every Stage_SNN.xpr.
//! cargo run --release --example capture_match -- <capture.log> <resource3d dir> //! cargo run --release --example capture_match -- <capture.log> <resource3d dir>
use std::collections::{BTreeMap, HashSet};
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model}; use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog}; use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use std::collections::{BTreeMap, HashSet};
fn main() { fn main() {
let a: Vec<String> = std::env::args().collect(); let a: Vec<String> = std::env::args().collect();
let text = std::fs::read_to_string(&a[1]).unwrap(); let text = std::fs::read_to_string(&a[1]).unwrap();
let mut draws = parse_capture(&text); let mut draws = parse_capture(&text);
if draws.is_empty() { if draws.is_empty() { draws = parse_drawlog(&text); }
draws = parse_drawlog(&text); let caps: HashSet<u32> = draws.iter().map(|d| d.vcount).filter(|v| *v >= 100).collect();
} eprintln!("{} draws, {} distinct vcounts>=100", draws.len(), caps.len());
let caps: HashSet<u32> = draws
.iter()
.map(|d| d.vcount)
.filter(|v| *v >= 100)
.collect();
eprintln!(
"{} draws, {} distinct vcounts>=100",
draws.len(),
caps.len()
);
let mut entries: Vec<_> = std::fs::read_dir(&a[2]) let mut entries: Vec<_> = std::fs::read_dir(&a[2]).unwrap().filter_map(|e| e.ok())
.unwrap() .filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n.starts_with("Stage_") && n.ends_with(".xpr") })
.filter_map(|e| e.ok()) .map(|e| e.path()).collect();
.filter(|e| {
let n = e.file_name().to_string_lossy().to_string();
n.starts_with("Stage_") && n.ends_with(".xpr")
})
.map(|e| e.path())
.collect();
entries.sort(); entries.sort();
for path in entries { for path in entries {
let bytes = std::fs::read(&path).unwrap(); let bytes = std::fs::read(&path).unwrap();
@@ -44,18 +28,12 @@ fn main() {
let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum(); let vc: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
if vc >= 100 && caps.contains(&(vc as u32)) { if vc >= 100 && caps.contains(&(vc as u32)) {
let id = m.name.get(..4).unwrap_or("?").to_string(); let id = m.name.get(..4).unwrap_or("?").to_string();
hits.entry(id) hits.entry(id).or_default().push((m.name.clone(), vc as u32));
.or_default()
.push((m.name.clone(), vc as u32));
} }
} }
let total: usize = hits.values().map(|v| v.len()).sum(); let total: usize = hits.values().map(|v| v.len()).sum();
if total >= 3 { if total >= 3 {
println!( println!("== {} : {} matching resources ==", path.file_name().unwrap().to_string_lossy(), total);
"== {} : {} matching resources ==",
path.file_name().unwrap().to_string_lossy(),
total
);
for (id, v) in &hits { for (id, v) in &hits {
let s: Vec<String> = v.iter().map(|(n, c)| format!("{n}({c})")).collect(); let s: Vec<String> = v.iter().map(|(n, c)| format!("{n}({c})")).collect();
println!(" {id}: {}", s.join(" ")); println!(" {id}: {}", s.join(" "));

View File

@@ -10,8 +10,8 @@
//! `Stage_S01` gave us. //! `Stage_S01` gave us.
//! //!
//! Usage: capture_truth_scan <resource3d_dir> <capture.log>... //! Usage: capture_truth_scan <resource3d_dir> <capture.log>...
use std::collections::HashMap;
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw}; use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw};
use std::collections::HashMap;
fn q(v: f32) -> i64 { fn q(v: f32) -> i64 {
(v as f64 * 1e4).round() as i64 (v as f64 * 1e4).round() as i64
@@ -47,9 +47,7 @@ fn main() {
let mut placed = 0usize; let mut placed = 0usize;
for f in &files { for f in &files {
let Ok(bytes) = std::fs::read(f) else { let Ok(bytes) = std::fs::read(f) else { continue };
continue;
};
// Index quantised position triples. Junk floats (NaN/huge) are skipped, // Index quantised position triples. Junk floats (NaN/huge) are skipped,
// which prunes most of a texture-heavy container. // which prunes most of a texture-heavy container.
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()); let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
@@ -57,12 +55,7 @@ fn main() {
let mut o = 0usize; let mut o = 0usize;
while o + 12 <= bytes.len() { while o + 12 <= bytes.len() {
let (x, y, z) = (be(o), be(o + 4), be(o + 8)); let (x, y, z) = (be(o), be(o + 4), be(o + 8));
if x.is_finite() if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6
&& y.is_finite()
&& z.is_finite()
&& x.abs() < 1e6
&& y.abs() < 1e6
&& z.abs() < 1e6
{ {
index.entry((q(x), q(y), q(z))).or_default().push(o as u32); index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
} }
@@ -84,8 +77,9 @@ fn main() {
let ok = (1..4).all(|j| { let ok = (1..4).all(|j| {
let at = off as usize + j * stride; let at = off as usize + j * stride;
at + 12 <= bytes.len() at + 12 <= bytes.len()
&& (0..3) && (0..3).all(|c| {
.all(|c| (be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4) (be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4
})
}); });
if ok { if ok {
deltas deltas

View File

@@ -71,27 +71,15 @@ fn main() {
for p in &base_parts { for p in &base_parts {
let mut vcounts = Vec::new(); let mut vcounts = Vec::new();
let mut pos = Vec::new(); let mut pos = Vec::new();
for cand in [ for cand in [p.clone(), format!("{p}_m"), format!("{p}_l"), format!("{p}_d")] {
p.clone(),
format!("{p}_m"),
format!("{p}_l"),
format!("{p}_d"),
] {
if let Some(m) = models.iter().find(|m| m.name == cand) { if let Some(m) = models.iter().find(|m| m.name == cand) {
let mp: Vec<[f32; 3]> = m let mp: Vec<[f32; 3]> =
.meshes m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect();
.iter()
.flat_map(|s| s.positions.iter().copied())
.collect();
vcounts.push(mp.len() as u32); vcounts.push(mp.len() as u32);
pos.extend(mp); pos.extend(mp);
} }
} }
parts.push(Part { parts.push(Part { base: p.clone(), vcounts, pos });
base: p.clone(),
vcounts,
pos,
});
} }
// Validate a draw against a part (direct or X-mirrored), like ship_capture. // Validate a draw against a part (direct or X-mirrored), like ship_capture.
@@ -100,9 +88,7 @@ fn main() {
return None; return None;
} }
let near = |a: &[f32; 3], b: &[f32; 3]| { let near = |a: &[f32; 3], b: &[f32; 3]| {
(a[0] - b[0]).abs() <= 1e-2 (a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
&& (a[1] - b[1]).abs() <= 1e-2
&& (a[2] - b[2]).abs() <= 1e-2
}; };
let (mut direct, mut mirror) = (0usize, 0usize); let (mut direct, mut mirror) = (0usize, 0usize);
for q in &d.pos { for q in &d.pos {
@@ -158,11 +144,7 @@ fn main() {
} }
} }
} }
eprintln!( eprintln!("{log}: {} validated draws, {} bdy_04 refs", labeled.len(), refs.len());
"{log}: {} validated draws, {} bdy_04 refs",
labeled.len(),
refs.len()
);
} }
// Cluster per part (greedy, 8-unit radius), print clusters with ≥3 samples. // Cluster per part (greedy, 8-unit radius), print clusters with ≥3 samples.
@@ -183,7 +165,7 @@ fn main() {
None => clusters.push((*s, 1)), None => clusters.push((*s, 1)),
} }
} }
clusters.sort_by_key(|x| std::cmp::Reverse(x.1)); clusters.sort_by(|x, y| y.1.cmp(&x.1));
let tops: Vec<String> = clusters let tops: Vec<String> = clusters
.iter() .iter()
.filter(|(_, n)| *n >= 3) .filter(|(_, n)| *n >= 3)

View File

@@ -22,19 +22,14 @@ fn main() {
let mut rows = vec![]; let mut rows = vec![];
for e in arc.entries() { for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue }; let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { let Ok(o) = IdxdObject::parse(&b) else { continue };
continue;
};
if o.schema_hash != 0x3c9ae32e { if o.schema_hash != 0x3c9ae32e {
continue; continue;
} }
let t = o.tokens(); let t = o.tokens();
let stage = t let stage = t
.iter() .iter()
.find_map(|s| { .find_map(|s| s.strip_prefix("EnumUnit_").map(|x| x.trim_end_matches(".tbl").to_string()))
s.strip_prefix("EnumUnit_")
.map(|x| x.trim_end_matches(".tbl").to_string())
})
.unwrap_or("?".into()); .unwrap_or("?".into());
let bg = o.get_raw("BackGroundID").unwrap_or("?").to_string(); let bg = o.get_raw("BackGroundID").unwrap_or("?").to_string();
rows.push((stage, bg, t.len())); rows.push((stage, bg, t.len()));
@@ -52,20 +47,11 @@ fn main() {
let mut all_tokens: Vec<(u32, Vec<String>)> = vec![]; let mut all_tokens: Vec<(u32, Vec<String>)> = vec![];
for e in ch.entries() { for e in ch.entries() {
let Ok(b) = ch.read(e) else { continue }; let Ok(b) = ch.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { let Ok(o) = IdxdObject::parse(&b) else { continue };
continue;
};
*by_schema.entry(o.schema_hash).or_default() += 1; *by_schema.entry(o.schema_hash).or_default() += 1;
all_tokens.push(( all_tokens.push((o.schema_hash, o.tokens().iter().map(|s| s.to_string()).collect()));
o.schema_hash,
o.tokens().iter().map(|s| s.to_string()).collect(),
));
} }
println!( println!(" {} entries, {} IDXD objects", ch.entries().len(), all_tokens.len());
" {} entries, {} IDXD objects",
ch.entries().len(),
all_tokens.len()
);
for (h, n) in &by_schema { for (h, n) in &by_schema {
println!(" schema {h:08x} x{n}"); println!(" schema {h:08x} x{n}");
} }
@@ -80,16 +66,10 @@ fn main() {
let mut hits: std::collections::BTreeSet<String> = Default::default(); let mut hits: std::collections::BTreeSet<String> = Default::default();
for e in arc.entries() { for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue }; let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else { let Ok(o) = IdxdObject::parse(&b) else { continue };
continue;
};
for t in o.tokens() { for t in o.tokens() {
let l = t.to_ascii_lowercase(); let l = t.to_ascii_lowercase();
if l.contains("challenge") if l.contains("challenge") || t.ends_with("_EX") || t.contains("_EX4") || t.contains("_EX5") {
|| t.ends_with("_EX")
|| t.contains("_EX4")
|| t.contains("_EX5")
{
hits.insert(format!("{:08x} {t}", o.schema_hash)); hits.insert(format!("{:08x} {t}", o.schema_hash));
} }
} }

View File

@@ -44,9 +44,7 @@ fn main() {
paks.sort(); paks.sort();
for p in &paks { for p in &paks {
let Ok(arc) = PakArchive::open(p) else { let Ok(arc) = PakArchive::open(p) else { continue };
continue;
};
for (i, e) in arc.entries().iter().enumerate() { for (i, e) in arc.entries().iter().enumerate() {
let Ok(b) = arc.read(e) else { continue }; let Ok(b) = arc.read(e) else { continue };
if KEYS.iter().filter(|k| find(&b, k.as_bytes())).count() < KEYS.len() { if KEYS.iter().filter(|k| find(&b, k.as_bytes())).count() < KEYS.len() {

View File

@@ -15,9 +15,7 @@ fn main() {
files.sort(); files.sort();
let (mut total, mut composite, mut composite_named) = (0usize, 0usize, 0usize); let (mut total, mut composite, mut composite_named) = (0usize, 0usize, 0usize);
for f in &files { for f in &files {
let Ok(bytes) = std::fs::read(f) else { let Ok(bytes) = std::fs::read(f) else { continue };
continue;
};
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) { for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
total += 1; total += 1;
let has_nodes = !scene_world_nodes(&bytes, &m.name).is_empty(); let has_nodes = !scene_world_nodes(&bytes, &m.name).is_empty();

View File

@@ -8,11 +8,8 @@
//! shift chain after the exact-coverage fix. //! shift chain after the exact-coverage fix.
//! //!
//! Usage: consensus_check <resource3d_dir> [--list] //! Usage: consensus_check <resource3d_dir> [--list]
use std::collections::{BTreeMap, HashMap};
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
use std::collections::{BTreeMap, HashMap};
/// Every place one model name was seen: (container, verts, tris, span).
type Sightings = BTreeMap<String, Vec<(String, usize, usize, [i64; 3])>>;
fn main() { fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir"); let dir = std::env::args().nth(1).expect("resource3d dir");
@@ -26,11 +23,9 @@ fn main() {
files.sort(); files.sort();
// name -> [(container, verts, tris, span)] // name -> [(container, verts, tris, span)]
let mut seen: Sightings = BTreeMap::new(); let mut seen: BTreeMap<String, Vec<(String, usize, usize, [i64; 3])>> = BTreeMap::new();
for f in &files { for f in &files {
let Ok(bytes) = std::fs::read(f) else { let Ok(bytes) = std::fs::read(f) else { continue };
continue;
};
let where_ = f.file_name().unwrap().to_string_lossy().to_string(); let where_ = f.file_name().unwrap().to_string_lossy().to_string();
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) { for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
@@ -52,9 +47,7 @@ fn main() {
(hi[1] - lo[1]).round() as i64, (hi[1] - lo[1]).round() as i64,
(hi[2] - lo[2]).round() as i64, (hi[2] - lo[2]).round() as i64,
]; ];
seen.entry(m.name.clone()) seen.entry(m.name.clone()).or_default().push((where_.clone(), v, t, span));
.or_default()
.push((where_.clone(), v, t, span));
} }
} }
@@ -88,5 +81,7 @@ fn main() {
println!("{r}"); println!("{r}");
} }
} }
println!("{minority} minority decodes across {resources} resources that have a majority"); println!(
"{minority} minority decodes across {resources} resources that have a majority"
);
} }

View File

@@ -16,18 +16,19 @@
//! Usage: //! Usage:
//! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \ //! SYLPHEED_ISO=... cargo run --release --example correlate_capture -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit] //! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
//! e.g. SYLPHEED_ISO="/path/to/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso" \ //! e.g. SYLPHEED_ISO="/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of
//! Deception (USA, Europe) (En,Ja).iso" \
//! cargo run --release --example correlate_capture -- \ //! cargo run --release --example correlate_capture -- \
//! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit //! xenia_ship_capture.log Stage_S01 e106 bdy_04 --emit
use std::collections::HashSet;
use std::path::Path;
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model}; use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{is_base_part, ship_id_of}; use sylpheed_formats::ship::{is_base_part, ship_id_of};
use sylpheed_formats::ship_capture::{ use sylpheed_formats::ship_capture::{
correlate, parse_capture, parse_drawlog, serialize_table, PartKey, correlate, parse_capture, parse_drawlog, serialize_table, PartKey,
}; };
use sylpheed_formats::xiso::open_iso; use sylpheed_formats::xiso::open_iso;
use std::collections::HashSet;
use std::path::Path;
fn main() { fn main() {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
@@ -57,15 +58,10 @@ fn main() {
// Decode the ship's base parts AND their LOD copies (vcount + leading // Decode the ship's base parts AND their LOD copies (vcount + leading
// positions are the match keys; LODs share the base part's local frame). // positions are the match keys; LODs share the base part's local frame).
let bytes = { let bytes = {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
.enable_all()
.build()
.unwrap();
rt.block_on(async { rt.block_on(async {
let mut r = open_iso(Path::new(&iso)).await.unwrap(); let mut r = open_iso(Path::new(&iso)).await.unwrap();
r.read_file(&format!("hidden/resource3d/{stage}.xpr")) r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
.await
.unwrap()
}) })
}; };
let names = xbg7_resource_names(&bytes); let names = xbg7_resource_names(&bytes);
@@ -87,12 +83,7 @@ fn main() {
let models = Xbg7Model::models_named(&bytes, &want, &|| false); let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> { let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
let m = models.iter().find(|m| m.name == name)?; let m = models.iter().find(|m| m.name == name)?;
Some( Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
m.meshes
.iter()
.flat_map(|s| s.positions.iter().copied())
.collect(),
)
}; };
// One PartKey per (part, variant-vcount present in the capture) — correlate // One PartKey per (part, variant-vcount present in the capture) — correlate
@@ -103,33 +94,18 @@ fn main() {
// variant is the same part in the same local frame. // variant is the same part in the same local frame.
let mut keys: Vec<PartKey> = Vec::new(); let mut keys: Vec<PartKey> = Vec::new();
for part in &base_parts { for part in &base_parts {
let variants = [ let variants =
part.clone(), [part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
format!("{part}_m"), let union: Vec<[f32; 3]> =
format!("{part}_l"), variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
format!("{part}_d"),
];
let union: Vec<[f32; 3]> = variants
.iter()
.filter_map(|v| positions_of(v))
.flatten()
.collect();
let mut any = false; let mut any = false;
for cand in &variants { for cand in &variants {
if let Some(pos) = positions_of(cand) { if let Some(pos) = positions_of(cand) {
let vcount = pos.len() as u32; let vcount = pos.len() as u32;
if draws.iter().any(|d| d.vcount == vcount) { if draws.iter().any(|d| d.vcount == vcount) {
let lod = if cand == part { let lod = if cand == part { "full" } else { cand.rsplit('_').next().unwrap_or("?") };
"full"
} else {
cand.rsplit('_').next().unwrap_or("?")
};
eprintln!(" {part:20} try vcount={vcount:6} [{lod}]"); eprintln!(" {part:20} try vcount={vcount:6} [{lod}]");
keys.push(PartKey { keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
part: part.clone(),
vcount,
ref_pos: union.clone(),
});
any = true; any = true;
} }
} }
@@ -144,15 +120,9 @@ fn main() {
return; return;
}; };
eprintln!( eprintln!("\nreference = {} → ship-relative placement:", ship.reference);
"\nreference = {} → ship-relative placement:",
ship.reference
);
for p in &ship.parts { for p in &ship.parts {
eprintln!( eprintln!(" {:18} T=[{:9.1}{:9.1}{:9.1}]", p.part, p.t[0], p.t[1], p.t[2]);
" {:18} T=[{:9.1}{:9.1}{:9.1}]",
p.part, p.t[0], p.t[1], p.t[2]
);
} }
for part in &base_parts { for part in &base_parts {
if !ship.parts.iter().any(|p| &p.part == part) { if !ship.parts.iter().any(|p| &p.part == part) {

View File

@@ -16,23 +16,19 @@
//! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \ //! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N] //! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N]
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model}; use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{is_base_part, ship_id_of}; use sylpheed_formats::ship::{is_base_part, ship_id_of};
use sylpheed_formats::ship_capture::{ use sylpheed_formats::ship_capture::{
correlate, parse_capture, parse_drawlog, segment_frames, PartKey, correlate, parse_capture, parse_drawlog, segment_frames, PartKey,
}; };
use sylpheed_formats::xiso::open_iso; use sylpheed_formats::xiso::open_iso;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
fn median(mut v: Vec<f32>) -> f32 { fn median(mut v: Vec<f32>) -> f32 {
v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = v.len(); let n = v.len();
if n % 2 == 1 { if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) }
v[n / 2]
} else {
0.5 * (v[n / 2 - 1] + v[n / 2])
}
} }
fn main() { fn main() {
@@ -58,22 +54,13 @@ fn main() {
draws = parse_drawlog(&text); draws = parse_drawlog(&text);
} }
let frames = segment_frames(&draws); let frames = segment_frames(&draws);
println!( println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len());
"{} draws → {} camera-consistent blocks",
draws.len(),
frames.len()
);
let bytes = { let bytes = {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
.enable_all()
.build()
.unwrap();
rt.block_on(async { rt.block_on(async {
let mut r = open_iso(Path::new(&iso)).await.unwrap(); let mut r = open_iso(Path::new(&iso)).await.unwrap();
r.read_file(&format!("hidden/resource3d/{stage}.xpr")) r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
.await
.unwrap()
}) })
}; };
let names = xbg7_resource_names(&bytes); let names = xbg7_resource_names(&bytes);
@@ -94,12 +81,7 @@ fn main() {
let models = Xbg7Model::models_named(&bytes, &want, &|| false); let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> { let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
let m = models.iter().find(|m| m.name == name)?; let m = models.iter().find(|m| m.name == name)?;
Some( Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
m.meshes
.iter()
.flat_map(|s| s.positions.iter().copied())
.collect(),
)
}; };
// part -> [T per frame], and how many frames placed it at all. // part -> [T per frame], and how many frames placed it at all.
@@ -109,33 +91,20 @@ fn main() {
for (fi, fr) in frames.iter().enumerate() { for (fi, fr) in frames.iter().enumerate() {
let mut keys: Vec<PartKey> = Vec::new(); let mut keys: Vec<PartKey> = Vec::new();
for part in &base_parts { for part in &base_parts {
let variants = [ let variants =
part.clone(), [part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
format!("{part}_m"), let union: Vec<[f32; 3]> =
format!("{part}_l"), variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
format!("{part}_d"),
];
let union: Vec<[f32; 3]> = variants
.iter()
.filter_map(|v| positions_of(v))
.flatten()
.collect();
for cand in &variants { for cand in &variants {
if let Some(pos) = positions_of(cand) { if let Some(pos) = positions_of(cand) {
let vcount = pos.len() as u32; let vcount = pos.len() as u32;
if fr.iter().any(|d| d.vcount == vcount) { if fr.iter().any(|d| d.vcount == vcount) {
keys.push(PartKey { keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
part: part.clone(),
vcount,
ref_pos: union.clone(),
});
} }
} }
} }
} }
let Some(ship) = correlate(id, fr, &keys, ref_sub) else { let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue };
continue;
};
if ship.parts.len() < min_parts { if ship.parts.len() < min_parts {
continue; continue;
} }
@@ -145,10 +114,7 @@ fn main() {
// Averaging them together is what makes an otherwise clean result look // Averaging them together is what makes an otherwise clean result look
// like it disagrees by exactly the distance between the two references. // like it disagrees by exactly the distance between the two references.
if !ship.reference.contains(ref_sub) { if !ship.reference.contains(ref_sub) {
println!( println!(" block {fi:2}: skipped — reference fell back to {}", ship.reference);
" block {fi:2}: skipped — reference fell back to {}",
ship.reference
);
continue; continue;
} }
used_frames += 1; used_frames += 1;
@@ -207,8 +173,7 @@ fn main() {
let spread: Vec<f32> = (0..3) let spread: Vec<f32> = (0..3)
.map(|a| { .map(|a| {
let v: Vec<f32> = cl.iter().map(|t| t[a]).collect(); let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
v.iter().cloned().fold(f32::MIN, f32::max) v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min)
- v.iter().cloned().fold(f32::MAX, f32::min)
}) })
.collect(); .collect();
let verdict = if cl.len() < 2 { let verdict = if cl.len() < 2 {
@@ -222,21 +187,14 @@ fn main() {
// (turret aiming, engine gimballing) does not — which is what separates // (turret aiming, engine gimballing) does not — which is what separates
// "the assembler has the rotation wrong" from "the part moved". // "the assembler has the rotation wrong" from "the part moved".
let all_ms = rots.get(part).cloned().unwrap_or_default(); let all_ms = rots.get(part).cloned().unwrap_or_default();
let ms: Vec<[[f32; 3]; 3]> = cl_idx let ms: Vec<[[f32; 3]; 3]> =
.iter() cl_idx.iter().filter_map(|&i| all_ms.get(i).copied()).collect();
.filter_map(|&i| all_ms.get(i).copied())
.collect();
// Keep a rotation from INSIDE the cluster: the first sample overall can // Keep a rotation from INSIDE the cluster: the first sample overall can
// belong to another instance, and diffing static against that reads as a // belong to another instance, and diffing static against that reads as a
// rotation error that is really an instance mix-up. // rotation error that is really an instance mix-up.
consensus.insert( consensus.insert(
part.clone(), part.clone(),
( (med, ms.first().copied().unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])),
med,
ms.first()
.copied()
.unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
),
); );
let rot_var = ms let rot_var = ms
.iter() .iter()
@@ -261,12 +219,8 @@ fn main() {
// re-expressed in the reference part's frame before comparing — and the // re-expressed in the reference part's frame before comparing — and the
// rotation is compared too, because "wrong orientation" is half of the // rotation is compared too, because "wrong orientation" is half of the
// reported viewer symptom and a translation-only check cannot see it. // reported viewer symptom and a translation-only check cannot see it.
let Some(si) = args.iter().position(|a| a == "--static") else { let Some(si) = args.iter().position(|a| a == "--static") else { return };
return; let Some(spath) = args.get(si + 1) else { return };
};
let Some(spath) = args.get(si + 1) else {
return;
};
let sbytes = std::fs::read(spath).expect("read stage container"); let sbytes = std::fs::read(spath).expect("read stage container");
// `include_external = true` — the engine cluster, the bridge and cross-id // `include_external = true` — the engine cluster, the bridge and cross-id
// turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the // turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the
@@ -315,19 +269,12 @@ fn main() {
.fold(0.0f32, f32::max); .fold(0.0f32, f32::max);
worst_t = worst_t.max(dtm); worst_t = worst_t.max(dtm);
worst_r = worst_r.max(drm); worst_r = worst_r.max(drm);
let mark = if dtm < 1.0 && drm < 0.02 { let mark = if dtm < 1.0 && drm < 0.02 { "MATCH" } else { "DIFFERS" };
"MATCH"
} else {
"DIFFERS"
};
println!( println!(
" {part:18} static=[{:9.1}{:9.1}{:9.1}] dT={dtm:7.2} dR={drm:6.3} {mark}", " {part:18} static=[{:9.1}{:9.1}{:9.1}] dT={dtm:7.2} dR={drm:6.3} {mark}",
r[0], r[1], r[2] r[0], r[1], r[2]
); );
} }
println!( println!("\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)",
"\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)", scene.len(), samples.len());
scene.len(),
samples.len()
);
} }

View File

@@ -6,8 +6,8 @@
//! is well founded. This measures the slack on every block that DOES decode: if //! is well founded. This measures the slack on every block that DOES decode: if
//! real geometry always covers its pool, under-coverage is good evidence of a //! real geometry always covers its pool, under-coverage is good evidence of a
//! wrong candidate and the gate stands. //! wrong candidate and the gate stands.
use std::collections::BTreeMap;
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
fn main() { fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir"); let dir = std::env::args().nth(1).expect("resource3d dir");
@@ -22,9 +22,7 @@ fn main() {
let mut hist: BTreeMap<i64, usize> = BTreeMap::new(); let mut hist: BTreeMap<i64, usize> = BTreeMap::new();
let mut worst: Vec<(i64, String)> = Vec::new(); let mut worst: Vec<(i64, String)> = Vec::new();
for f in &files { for f in &files {
let Ok(bytes) = std::fs::read(f) else { let Ok(bytes) = std::fs::read(f) else { continue };
continue;
};
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) { for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
for sub in &m.meshes { for sub in &m.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() { if sub.positions.is_empty() || sub.indices.is_empty() {
@@ -34,21 +32,14 @@ fn main() {
let slack = sub.positions.len() as i64 - 1 - max_idx; let slack = sub.positions.len() as i64 - 1 - max_idx;
*hist.entry(slack.min(20)).or_default() += 1; *hist.entry(slack.min(20)).or_default() += 1;
if slack > 4 { if slack > 4 {
worst.push(( worst.push((slack, format!("{} in {}", m.name, f.file_name().unwrap().to_string_lossy())));
slack,
format!("{} in {}", m.name, f.file_name().unwrap().to_string_lossy()),
));
} }
} }
} }
} }
println!("unreferenced tail vertices (vtx_count 1 max index), over decoded sub-meshes:"); println!("unreferenced tail vertices (vtx_count 1 max index), over decoded sub-meshes:");
for (slack, n) in &hist { for (slack, n) in &hist {
println!( println!(" {:>3}{} : {n}", slack, if *slack == 20 { "+" } else { " " });
" {:>3}{} : {n}",
slack,
if *slack == 20 { "+" } else { " " }
);
} }
worst.sort_by_key(|(s, _)| std::cmp::Reverse(*s)); worst.sort_by_key(|(s, _)| std::cmp::Reverse(*s));
for (s, w) in worst.iter().take(5) { for (s, w) in worst.iter().take(5) {

View File

@@ -12,13 +12,7 @@ use sylpheed_formats::{movie_subtitle, PakArchive};
fn movie_secs(path: &str) -> Option<f32> { fn movie_secs(path: &str) -> Option<f32> {
let out = Command::new("ffprobe") let out = Command::new("ffprobe")
.args([ .args([
"-v", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path,
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
path,
]) ])
.output() .output()
.ok()?; .ok()?;
@@ -33,9 +27,7 @@ fn main() {
let mut over = 0; let mut over = 0;
let mut worst: Vec<(String, f32, f32)> = Vec::new(); let mut worst: Vec<(String, f32, f32)> = Vec::new();
let dir = format!("{disc}/dat/movie"); let dir = format!("{disc}/dat/movie");
let Ok(rd) = std::fs::read_dir(&dir) else { let Ok(rd) = std::fs::read_dir(&dir) else { return };
return;
};
for e in rd.flatten() { for e in rd.flatten() {
let p = e.path(); let p = e.path();
if p.extension().is_none_or(|x| x != "wmv") { if p.extension().is_none_or(|x| x != "wmv") {
@@ -47,9 +39,7 @@ fn main() {
continue; continue;
} }
let last = cues.iter().map(|(_, t)| *t).fold(0.0f32, f32::max); let last = cues.iter().map(|(_, t)| *t).fold(0.0f32, f32::max);
let Some(secs) = movie_secs(&p.to_string_lossy()) else { let Some(secs) = movie_secs(&p.to_string_lossy()) else { continue };
continue;
};
checked += 1; checked += 1;
if last > secs { if last > secs {
over += 1; over += 1;

View File

@@ -1,65 +0,0 @@
//! Does a textured element's declaration carry anything that distinguishes the
//! two FRAME elements from every other element on the main menu?
//!
//! `sylpheed-port` asks for the blend/alpha mode of `ptframe1`/`ptframe2`. Prior
//! work is on `.prm` PRIMITIVES (`ui-prm-blend-mode.md`, undecodable with reach —
//! no field, the declaration words are constant) and on a refuted `T8aD +0x04`
//! bit. Neither covers a `.t32` element's own declaration entry, which is 60
//! bytes and mostly unread.
//!
//! This dumps every declaration entry on the menu and reports, per 4-byte word,
//! whether the two frames share a value that no other element has. A word that
//! separates exactly those two is a candidate; one that does not, is not.
//!
//! cargo run -p sylpheed-formats --example decl_entry_diff
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const AT: usize = 0x20;
const N: usize = 60;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let by = ar.read(&ar.entries()[5]).expect("entry 5");
let b = ui_layout::parse_build(&by).expect("build");
let names: Vec<String> = b.elements.iter().map(|e| e.name.clone()).collect();
let frames: Vec<usize> = names
.iter()
.enumerate()
.filter(|(_, n)| n.starts_with("ptframe"))
.map(|(i, _)| i)
.collect();
println!("{} elements; frames at indices {:?}", names.len(), frames);
let word = |i: usize, w: usize| -> u32 {
let o = AT + i * N + w * 4;
u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]])
};
println!("\nper-word: does a value separate EXACTLY the two frames?");
for w in 0..N / 4 {
let fv: Vec<u32> = frames.iter().map(|&i| word(i, w)).collect();
let same_in_frames = fv.windows(2).all(|p| p[0] == p[1]);
let others: Vec<u32> = (0..names.len())
.filter(|i| !frames.contains(i))
.map(|i| word(i, w))
.collect();
let unique = same_in_frames && !others.contains(&fv[0]);
let distinct = {
let mut v: Vec<u32> = (0..names.len()).map(|i| word(i, w)).collect();
v.sort_unstable();
v.dedup();
v.len()
};
println!(
" +0x{:02X} frames {:?} distinct values {distinct:2}{}",
w * 4,
fv.iter().map(|v| format!("{v:08X}")).collect::<Vec<_>>(),
if unique {
" <- SEPARATES THE FRAMES"
} else {
""
}
);
}
}

View File

@@ -1,45 +0,0 @@
//! Which elements carry which values at the low-cardinality declaration words?
//!
//! `decl_entry_diff` found nothing separating `ptframe1`/`ptframe2` except their
//! NAME — +0x00 and +0x08 are the name string ("ptfr", ".t32"), so those two hits
//! are a false positive of that test, not a field.
//!
//! The remaining candidates for a per-element mode flag are the words with few
//! distinct values: +0x28 (3) and +0x2C (6). This prints who has what.
//!
//! cargo run -p sylpheed-formats --example decl_flag_words
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const AT: usize = 0x20;
const N: usize = 60;
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let by = ar.read(&ar.entries()[5]).expect("entry 5");
let b = ui_layout::parse_build(&by).expect("build");
let w = |i: usize, off: usize| -> u32 {
let o = AT + i * N + off;
u32::from_be_bytes([by[o], by[o + 1], by[o + 2], by[o + 3]])
};
println!(
"{:<22} {:>10} {:>10} {:>10} {:>10}",
"element", "+0x28", "+0x2C", "+0x34", "kind"
);
for (i, e) in b.elements.iter().enumerate() {
let mark = if e.name.starts_with("ptframe") {
" <- FRAME"
} else {
""
};
println!(
"{:<22} {:>10} {:>10} {:>10} {:>#10x}{mark}",
e.name,
w(i, 0x28),
w(i, 0x2C) as i32,
w(i, 0x34),
e.kind
);
}
}

View File

@@ -16,16 +16,12 @@ use sylpheed_formats::{pak, ratc, ui_layout};
const OFFS: [usize; 4] = [28, 36, 44, 56]; const OFFS: [usize; 4] = [28, 36, 44, 56];
fn be32(b: &[u8], o: usize) -> u32 { fn be32(b: &[u8], o: usize) -> u32 {
if o + 4 > b.len() { if o + 4 > b.len() { return 0; }
return 0;
}
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
} }
fn main() { fn main() {
let path = std::env::args() let path = std::env::args().nth(1).expect("usage: decl_word_probe <pak> [entry]");
.nth(1)
.expect("usage: decl_word_probe <pak> [entry]");
let want: Option<usize> = std::env::args().nth(2).and_then(|s| s.parse().ok()); let want: Option<usize> = std::env::args().nth(2).and_then(|s| s.parse().ok());
let ar = pak::PakArchive::open(&path).expect("open pak"); let ar = pak::PakArchive::open(&path).expect("open pak");
let entries: Vec<_> = ar.entries().to_vec(); let entries: Vec<_> = ar.entries().to_vec();
@@ -33,62 +29,34 @@ fn main() {
let mut hit = [0usize; 4]; let mut hit = [0usize; 4];
let mut tot = 0usize; let mut tot = 0usize;
for (i, e) in entries.iter().enumerate() { for (i, e) in entries.iter().enumerate() {
if want.is_some_and(|w| w != i) { if want.is_some_and(|w| w != i) { continue; }
continue;
}
let Ok(bytes) = ar.read(e) else { continue }; let Ok(bytes) = ar.read(e) else { continue };
let Some(build) = ui_layout::parse_build(&bytes) else { let Some(build) = ui_layout::parse_build(&bytes) else { continue };
continue; let Some(kids) = ratc::parse(&bytes) else { continue };
};
let Some(kids) = ratc::parse(&bytes) else {
continue;
};
// Index space to test against: the T8aD children, in child order. // Index space to test against: the T8aD children, in child order.
let t8: Vec<&ratc::RatcChild> = kids.iter().filter(|c| c.kind == "T8aD").collect(); let t8: Vec<&ratc::RatcChild> = kids.iter().filter(|c| c.kind == "T8aD").collect();
if build if build.elements.iter().all(|el| el.sprite.is_some() || el.kind & 0x10 != 0) {
.elements
.iter()
.all(|el| el.sprite.is_some() || el.kind & 0x10 != 0)
{
continue; continue;
} }
println!( println!("== entry {i} ({} elements, {} T8aD children)", build.elements.len(), t8.len());
"== entry {i} ({} elements, {} T8aD children)", for (n, c) in t8.iter().enumerate() { println!(" child[{n:2}] {}", c.name); }
build.elements.len(),
t8.len()
);
for (n, c) in t8.iter().enumerate() {
println!(" child[{n:2}] {}", c.name);
}
for el in &build.elements { for el in &build.elements {
if el.kind & 0x10 != 0 { if el.kind & 0x10 != 0 { continue; }
continue;
}
let d = &bytes[0x20 + el.index * 60..0x20 + (el.index + 1) * 60]; let d = &bytes[0x20 + el.index * 60..0x20 + (el.index + 1) * 60];
let words: Vec<u32> = OFFS.iter().map(|&o| be32(d, o)).collect(); let words: Vec<u32> = OFFS.iter().map(|&o| be32(d, o)).collect();
// The control: for a RESOLVED element, which T8aD child is it? // The control: for a RESOLVED element, which T8aD child is it?
let truth = el let truth = el.sprite.as_ref()
.sprite
.as_ref()
.and_then(|s| t8.iter().position(|c| &c.name == s)); .and_then(|s| t8.iter().position(|c| &c.name == s));
if let Some(t) = truth { if let Some(t) = truth {
tot += 1; tot += 1;
for (k, w) in words.iter().enumerate() { for (k, w) in words.iter().enumerate() {
if *w as usize == t { if *w as usize == t { hit[k] += 1; }
hit[k] += 1;
}
} }
} }
println!( println!(
" [{:2}] {:26} sprite={:?} child={:?} +28={} +36={} +44={} +56={}", " [{:2}] {:26} sprite={:?} child={:?} +28={} +36={} +44={} +56={}",
el.index, el.index, el.name, el.sprite, truth,
el.name, words[0] as i32, words[1] as i32, words[2] as i32, words[3] as i32
el.sprite,
truth,
words[0] as i32,
words[1] as i32,
words[2] as i32,
words[3] as i32
); );
} }
} }

View File

@@ -1,108 +1,59 @@
use sylpheed_formats::{ratc, idxd::IdxdObject, PakArchive};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use sylpheed_formats::{idxd::IdxdObject, ratc, PakArchive}; fn main(){
fn main() { let disc=std::env::var("SYLPHEED_DISC").unwrap();
let disc = std::env::var("SYLPHEED_DISC").unwrap();
// 1. RATC child-type census across the big RATC paks // 1. RATC child-type census across the big RATC paks
let mut childtypes: BTreeMap<String, u64> = BTreeMap::new(); let mut childtypes:BTreeMap<String,u64>=BTreeMap::new();
let mut ratc_unparsed = 0u64; let mut ratc_unparsed=0u64;
for pk in [ for pk in ["GP_READY_ROOM","GP_MOVIE_THEATER","GP_DIALOG","GP_DEBRIEFING_PILOTLOG","GP_TITLE","GP_BUNK"]{
"GP_READY_ROOM", let Ok(arc)=PakArchive::open(format!("{disc}/dat/{pk}.pak")) else{continue};
"GP_MOVIE_THEATER", for e in arc.entries(){
"GP_DIALOG", let Ok(b)=arc.read(e) else{continue};
"GP_DEBRIEFING_PILOTLOG", if !ratc::is_ratc(&b){continue;}
"GP_TITLE", match ratc::parse(&b){
"GP_BUNK", Some(kids)=> for k in kids{
] { let ext=k.name.rsplit('.').next().unwrap_or("?").to_lowercase();
let Ok(arc) = PakArchive::open(format!("{disc}/dat/{pk}.pak")) else { *childtypes.entry(ext).or_default()+=1;
continue; },
}; None=>ratc_unparsed+=1,
for e in arc.entries() {
let Ok(b) = arc.read(e) else { continue };
if !ratc::is_ratc(&b) {
continue;
}
match ratc::parse(&b) {
Some(kids) => {
for k in kids {
let ext = k.name.rsplit('.').next().unwrap_or("?").to_lowercase();
*childtypes.entry(ext).or_default() += 1;
}
}
None => ratc_unparsed += 1,
} }
} }
} }
println!("=== RATC child-type census (big RATC paks) ==="); println!("=== RATC child-type census (big RATC paks) ===");
let mut cv: Vec<_> = childtypes.into_iter().collect(); let mut cv:Vec<_>=childtypes.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1));
cv.sort_by_key(|x| std::cmp::Reverse(x.1)); for (e,c) in &cv{ println!(" .{e:8} ×{c}"); }
for (e, c) in &cv {
println!(" .{e:8} ×{c}");
}
println!(" (RATC bundles that failed to parse: {ratc_unparsed})"); println!(" (RATC bundles that failed to parse: {ratc_unparsed})");
// 2. The 00000002 mystery format (GP_MAIN_GAME_E) // 2. The 00000002 mystery format (GP_MAIN_GAME_E)
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in arc.entries() { for e in arc.entries(){
let Ok(b) = arc.read(e) else { continue }; let Ok(b)=arc.read(e) else{continue};
if b.len() >= 4 && b[0..4] == [0, 0, 0, 2] { if b.len()>=4 && b[0..4]==[0,0,0,2]{
let hx: String = b[..48.min(b.len())] let hx:String=b[..48.min(b.len())].iter().map(|x|format!("{x:02x}")).collect::<Vec<_>>().join(" ");
.iter() let asc:String=b[..64.min(b.len())].iter().map(|&x|if(0x20..0x7f).contains(&x){x as char}else{'.'}).collect();
.map(|x| format!("{x:02x}")) println!("\n=== 00000002 format sample ({}B) ===\n{hx}\n{asc}",b.len());
.collect::<Vec<_>>()
.join(" ");
let asc: String = b[..64.min(b.len())]
.iter()
.map(|&x| {
if (0x20..0x7f).contains(&x) {
x as char
} else {
'.'
}
})
.collect();
println!(
"\n=== 00000002 format sample ({}B) ===\n{hx}\n{asc}",
b.len()
);
break; break;
} }
} }
// 3. Sample the top undecoded IDXD schemas: first tokens (guess semantics) // 3. Sample the top undecoded IDXD schemas: first tokens (guess semantics)
println!("\n=== top undecoded IDXD schemas — sample tokens (semantic hints) ==="); println!("\n=== top undecoded IDXD schemas — sample tokens (semantic hints) ===");
let targets: [u32; 6] = [ let targets:[u32;6]=[0xb412e6d8,0x026379ab,0x43faa517,0x3c5b0549,0x6ab4825a,0x0426e81d];
0xb412e6d8, 0x026379ab, 0x43faa517, 0x3c5b0549, 0x6ab4825a, 0x0426e81d, for want in targets{
]; let mut shown=false;
for want in targets { for e in arc.entries(){
let mut shown = false; if shown{break;}
for e in arc.entries() { let Ok(b)=arc.read(e) else{continue};
if shown { if b.len()<12 || &b[0..4]!=b"IDXD"{continue;}
break; let s=u32::from_be_bytes([b[8],b[9],b[10],b[11]]);
} if s!=want{continue;}
let Ok(b) = arc.read(e) else { continue }; if let Ok(o)=IdxdObject::parse(&b){
if b.len() < 12 || &b[0..4] != b"IDXD" { let t=o.tokens();
continue; let sample:Vec<String>=t.iter().take(14).map(|s|{let s=s.chars().take(18).collect::<String>();s}).collect();
}
let s = u32::from_be_bytes([b[8], b[9], b[10], b[11]]);
if s != want {
continue;
}
if let Ok(o) = IdxdObject::parse(&b) {
let t = o.tokens();
let sample: Vec<String> = t
.iter()
.take(14)
.map(|s| {
let s = s.chars().take(18).collect::<String>();
s
})
.collect();
println!(" {want:08x}: {sample:?}"); println!(" {want:08x}: {sample:?}");
shown = true; shown=true;
} }
} }
if !shown { if !shown{ println!(" {want:08x}: (parse failed / not found)"); }
println!(" {want:08x}: (parse failed / not found)");
}
} }
} }

View File

@@ -13,11 +13,8 @@ fn is_number(s: &str) -> bool {
} }
fn is_key(s: &str) -> bool { fn is_key(s: &str) -> bool {
!s.is_empty() !s.is_empty()
&& s.chars() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') && s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s) && !is_number(s)
} }
fn is_value(s: &str) -> bool { fn is_value(s: &str) -> bool {
@@ -25,20 +22,14 @@ fn is_value(s: &str) -> bool {
} }
fn main() { fn main() {
// No hardcoded fallback: it made SYLPHEED_DISC look like a control while let root = std::env::var("SYLPHEED_DISC")
// one machine's directory layout decided the outcome (#16). .unwrap_or_else(|_| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
let Ok(root) = std::env::var("SYLPHEED_DISC") else {
eprintln!("set SYLPHEED_DISC to the extracted disc root");
std::process::exit(2);
};
let wanted: Vec<String> = std::env::args().skip(1).collect(); let wanted: Vec<String> = std::env::args().skip(1).collect();
let arc = PakArchive::open(std::path::Path::new(&root).join("dat/GP_MAIN_GAME_E.pak")).unwrap(); let arc = PakArchive::open(std::path::Path::new(&root).join("dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in arc.entries() { for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue }; let Ok(bytes) = arc.read(e) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
continue;
};
let toks = obj.tokens().to_vec(); let toks = obj.tokens().to_vec();
let mut hits: Vec<String> = vec![]; let mut hits: Vec<String> = vec![];
for (i, t) in toks.iter().enumerate() { for (i, t) in toks.iter().enumerate() {
@@ -53,12 +44,7 @@ fn main() {
}); });
} }
if !hits.is_empty() { if !hits.is_empty() {
println!( println!("0x{:08x} {:<44} {}", obj.schema_hash, obj.identity(), hits.join(" "));
"0x{:08x} {:<44} {}",
obj.schema_hash,
obj.identity(),
hits.join(" ")
);
} }
} }
} }

View File

@@ -20,9 +20,7 @@ fn is_key(s: &str) -> bool {
!s.is_empty() !s.is_empty()
&& s.chars() && s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& s.chars() && s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s) && !is_number(s)
} }
@@ -31,14 +29,9 @@ fn is_value(s: &str) -> bool {
} }
fn main() { fn main() {
// argv[1], else SYLPHEED_DISC. No hardcoded fallback -- see #16.
let root = std::env::args() let root = std::env::args()
.nth(1) .nth(1)
.or_else(|| std::env::var("SYLPHEED_DISC").ok()) .unwrap_or_else(|| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
.unwrap_or_else(|| {
eprintln!("usage: defaulted_fields <disc-root> [paks...] (or set SYLPHEED_DISC)");
std::process::exit(2);
});
let paks: Vec<String> = std::env::args().skip(2).collect(); let paks: Vec<String> = std::env::args().skip(2).collect();
let paks = if paks.is_empty() { let paks = if paks.is_empty() {
vec![ vec![
@@ -78,11 +71,7 @@ fn main() {
} }
let prev = if i == 0 { None } else { Some(&toks[i - 1]) }; let prev = if i == 0 { None } else { Some(&toks[i - 1]) };
let valued = prev.map(|p| is_value(p)).unwrap_or(false); let valued = prev.map(|p| is_value(p)).unwrap_or(false);
let v = if valued { let v = if valued { Some(toks[i - 1].clone()) } else { None };
Some(toks[i - 1].clone())
} else {
None
};
seen_here.entry(t.clone()).or_insert(v); seen_here.entry(t.clone()).or_insert(v);
} }
for (k, v) in seen_here { for (k, v) in seen_here {
@@ -118,14 +107,17 @@ fn main() {
0xb412_e6d8 => "MESSAGE", 0xb412_e6d8 => "MESSAGE",
_ => "?", _ => "?",
}; };
let defaulted: Vec<_> = keys.iter().filter(|(_, s)| s.1 > 0).collect(); let defaulted: Vec<_> = keys
.iter()
.filter(|(_, s)| s.1 > 0)
.collect();
if defaulted.is_empty() { if defaulted.is_empty() {
continue; continue;
} }
println!("\n--- schema 0x{schema:08x} {name} ({n_obj} objects) ---"); println!("\n--- schema 0x{schema:08x} {name} ({n_obj} objects) ---");
println!( println!(
"{:<30} {:>5} {:>5} values seen (≤12) | owners defaulting", "{:<30} {:>5} {:>5} {}",
"KEY", "set", "dflt" "KEY", "set", "dflt", "values seen (≤12) | owners defaulting"
); );
for (k, (n_set, n_def, vals, owners)) in defaulted { for (k, (n_set, n_def, vals, owners)) in defaulted {
let vv: Vec<&str> = vals.iter().map(|s| s.as_str()).collect(); let vv: Vec<&str> = vals.iter().map(|s| s.as_str()).collect();

View File

@@ -49,16 +49,12 @@ fn main() {
if r + 12 > desc.len() { if r + 12 > desc.len() {
break; break;
} }
let (o, code, usage) = let (o, code, usage) = (be32(desc, r), be32(desc, r + 4), be32(desc, r + 8) >> 16);
(be32(desc, r), be32(desc, r + 4), be32(desc, r + 8) >> 16);
if o == 0x00FF_0000 || code == 0xFFFF_FFFF || o > 0x1000 { if o == 0x00FF_0000 || code == 0xFFFF_FFFF || o > 0x1000 {
println!(" end marker @0x{r:X}: off=0x{o:X} code=0x{code:X}"); println!(" end marker @0x{r:X}: off=0x{o:X} code=0x{code:X}");
break; break;
} }
println!( println!(" off {o:>3} code 0x{:06X} usage {usage}", code & 0xFF_FFFF);
" off {o:>3} code 0x{:06X} usage {usage}",
code & 0xFF_FFFF
);
stride = stride.max(o + 4); stride = stride.max(o + 4);
r += 12; r += 12;
} }

View File

@@ -1,82 +0,0 @@
//! How often is a screen's design size READ, and how often is it FABRICATED?
//!
//! `ui_layout.rs` scans the `.rat` records for a `(w,h)` at `+0x18`/`+0x1c` and,
//! finding none, falls back to `(DESIGN_W, DESIGN_H)` = 1280x720. Its own comment
//! says "every screen seen is 1280x720, **which is also the fallback**" -- which
//! is precisely the problem: the fabricated value equals the expected one, so no
//! output of the parser can distinguish a read design size from an invented one.
//! The port sizes its screens off this number.
//!
//! This replicates the scan through the public RATC API and counts.
//!
//! cargo run -p sylpheed-formats --example design_size_fallback
use std::io::Write;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ratc, ui_layout};
fn be32(b: &[u8], o: usize) -> u32 {
if o + 4 > b.len() {
return 0;
}
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
let (mut read, mut fell_back, mut nonstd) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
let name = pak.file_name().unwrap().to_string_lossy().to_string();
let (mut r, mut f) = (0usize, 0usize);
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if !ui_layout::is_build(&by) {
continue;
}
let Some(kids) = ratc::parse(&by) else {
continue;
};
// the same predicate ui_layout uses, over the same records
// ⚠️ A first version took EVERY RATC child and failed its control:
// it reported all 965 builds stating a non-1280x720 size, where
// `screen list` prints 1280x720 for every one. `records` in
// ui_layout is the `.rat` children only; a T8aD sprite header read
// at +0x18 is garbage that passes the range test.
let found = kids
.iter()
.filter(|k| k.kind == "RATC" || k.name.ends_with(".rat"))
.find_map(|k| {
let rec = &by[k.offset..(k.offset + k.size).min(by.len())];
let (w, h) = (be32(rec, 0x18), be32(rec, 0x1c));
(w > 0 && h > 0 && w <= 8192 && h <= 8192).then_some((w, h))
});
match found {
Some((w, h)) => {
r += 1;
if (w, h) != (1280, 720) {
nonstd += 1;
println!(" {name} : a build states a NON-standard design size {w}x{h}");
}
}
None => f += 1,
}
}
if r + f > 0 {
println!("{name:30} {r:5} read {f:5} FABRICATED");
std::io::stdout().flush().ok();
}
read += r;
fell_back += f;
}
println!("\n{read} builds state a design size, {fell_back} get the 1280x720 FALLBACK");
println!("{nonstd} builds state something other than 1280x720");
println!("--- END ---");
}

View File

@@ -1,59 +0,0 @@
//! Is `GP_DIALOG` entry 2 the `DLG_SELECT_DIFFICULTY` screen?
//!
//! The image lists `DLG_SELECT_DIFFICULTY` among the `DLG_*` names at
//! `0x820A41BB`, so DIFFICULTY is a DIALOG, not a GamePart screen with its own
//! pak — which is why a search for an 8-record `btn` build in a difficulty-named
//! archive found nothing. `GP_DIALOG` entry 2 carries `pcbtn00`..`03`: four
//! buttons, matching EASY / NORMAL / HARD / BACK.
//!
//! CONTROL: `GP_TITLE` entry 5's five buttons must come back at the rows the disc
//! is independently known to place them (162/242/322/401/482, spacing 80).
//!
//! cargo run -p sylpheed-formats --example dialog_button_rows
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn rows(ar: &PakArchive, entry: usize, what: &str) {
let Ok(by) = ar.read(&ar.entries()[entry]) else {
return;
};
let Some(b) = ui_layout::parse_build(&by) else {
return;
};
let mut v: Vec<(i32, String)> = b
.elements
.iter()
.filter(|e| {
let n = &e.name;
(n.starts_with("pcbtn") || n.starts_with("ptbtn")) && !n.contains('f')
})
.map(|e| {
(
e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32),
e.name.clone(),
)
})
.collect();
v.sort_by_key(|r| r.0);
println!("\n{what} (entry {entry}):");
for (y, n) in &v {
println!(" y {y:5} {n}");
}
if v.len() > 1 {
let sp: Vec<i32> = v.windows(2).map(|w| w[1].0 - w[0].0).collect();
println!(" spacing {sp:?}");
}
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let t = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
rows(
&t,
5,
"CONTROL: GP_TITLE main menu (must be 162/242/322/401/482)",
);
let d = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
rows(&d, 2, "GP_DIALOG candidate for DLG_SELECT_DIFFICULTY");
rows(&d, 3, "GP_DIALOG entry 3 (the pair)");
}

View File

@@ -1,70 +0,0 @@
//! Are the 37 equal-button-count `GP_DIALOG` pairs language pairs, or not?
//!
//! 26 of 65 adjacent pairs differ in BUTTON COUNT — two languages cannot, so those
//! are unrelated dialogs. For the rest my language reading was left UNSUPPORTED
//! rather than refuted, and both agents observed that nothing rewards closing it.
//!
//! A language pair must share its BUTTON NAMES and ROWS exactly (a locale changes
//! glyphs, not layout) and differ only elsewhere. An unrelated pair will differ in
//! button names or rows too.
//!
//! CONTROL: entries 2/3 (identical element sets) must come out as "buttons match".
//!
//! cargo run -p sylpheed-formats --example dialog_pair_37
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn sig(ar: &PakArchive, i: usize) -> Option<(Vec<(String, i32)>, usize)> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
let mut v: Vec<(String, i32)> = b
.elements
.iter()
.filter(|e| e.name.contains("btn"))
.map(|e| {
(
e.name.clone(),
e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32),
)
})
.collect();
v.sort();
let n = v.len();
Some((v, n))
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let (mut match_btn, mut differ_btn, mut ctrl) = (0, 0, false);
let mut examples = 0;
for k in (0..n).step_by(2) {
let (Some((a, na)), Some((b, nb))) = (sig(&ar, k), sig(&ar, k + 1)) else {
continue;
};
if na != nb {
continue; // the 26 already settled
}
if a == b {
match_btn += 1;
if k == 2 {
ctrl = true
}
} else {
differ_btn += 1;
if examples < 5 {
println!(" entries {k:3}/{:<3} buttons DIFFER", k + 1);
println!(" {:?}", a.iter().map(|x| &x.0).collect::<Vec<_>>());
println!(" {:?}", b.iter().map(|x| &x.0).collect::<Vec<_>>());
examples += 1;
}
}
}
println!("\nequal-button-count pairs whose button NAMES+ROWS match : {match_btn}");
println!("equal-button-count pairs whose buttons DIFFER : {differ_btn}");
println!(
"control (entries 2/3 counted as matching): {}",
if ctrl { "PASSED" } else { "FAILED" }
);
}

View File

@@ -1,45 +0,0 @@
//! Do adjacent `GP_DIALOG` entries differ in BUTTON COUNT?
//!
//! I offered an untested reading for why 63 of 65 adjacent pairs have different
//! element sets: dialog text baked into language-specific sprites, so EN/JP
//! entries differ by construction. `sylpheed-port` refuted it with a count — two
//! languages of one dialog cannot differ in how many buttons they have. This
//! re-derives that with my own reader before I record the refutation.
//!
//! cargo run -p sylpheed-formats --example dialog_pair_button_counts
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn btns(ar: &PakArchive, i: usize) -> Option<usize> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().filter(|e| e.name.contains("btn")).count())
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let (mut diff, mut same, mut skip) = (0, 0, 0);
let mut show = 0;
for k in (0..n).step_by(2) {
match (btns(&ar, k), btns(&ar, k + 1)) {
(Some(a), Some(b)) => {
if a != b {
diff += 1;
if show < 6 {
println!(" entries {k:3}/{:<3} button counts {a} vs {b}", k + 1);
show += 1;
}
} else {
same += 1
}
}
_ => skip += 1,
}
}
println!("\nadjacent pairs differing in BUTTON COUNT: {diff}");
println!("adjacent pairs with equal button counts : {same}");
println!("unreadable : {skip}");
println!("\ntwo languages of one dialog cannot differ in button count.");
}

View File

@@ -1,51 +0,0 @@
//! What differs between equal-button-count `GP_DIALOG` adjacent pairs?
//!
//! All 39 share button names and rows. That is consistent with a LANGUAGE PAIR and
//! equally with TWO DIALOGS SHARING A BUTTON TEMPLATE (two yes/no boxes differing
//! only in their message sprite). The difference is in what else differs: a
//! language pair should differ in the SAME slots with locale-marked names.
//!
//! cargo run -p sylpheed-formats --example dialog_pair_diffs
use std::collections::BTreeSet;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn names(ar: &PakArchive, i: usize) -> Option<BTreeSet<String>> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|e| e.name.clone()).collect())
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let mut shown = 0;
for k in (0..n).step_by(2) {
let (Some(a), Some(b)) = (names(&ar, k), names(&ar, k + 1)) else {
continue;
};
if a == b {
continue;
}
let oa: Vec<&String> = a.difference(&b).collect();
let ob: Vec<&String> = b.difference(&a).collect();
// only the equal-button-count ones
let ba = a.iter().filter(|x| x.contains("btn")).count();
let bb = b.iter().filter(|x| x.contains("btn")).count();
if ba != bb {
continue;
}
if shown < 6 {
println!(
"entries {k:3}/{:<3} shared {} only-in-{k}: {:?} only-in-{}: {:?}",
k + 1,
a.intersection(&b).count(),
oa,
k + 1,
ob
);
shown += 1;
}
}
}

View File

@@ -1,58 +0,0 @@
//! Are `GP_DIALOG` entries 0/1 and 2/3 a LANGUAGE PAIR or a DUPLICATE?
//!
//! They are the only two adjacent pairs in that archive with identical element
//! sets; every other adjacent pair is two unrelated dialogs. Left open as
//! "untested" — identical element names are equally consistent with a language
//! pair (same layout, different glyphs baked into the textures) and with a
//! byte-for-byte duplicate.
//!
//! The bytes decide it: identical entries are a duplicate; entries that share
//! every element name but differ in payload are a language pair.
//!
//! CONTROL: entries 10/11, known to be two DIFFERENT dialogs (stage 10 vs stage
//! 02), must come out as differing — and by a lot. A comparator that cannot
//! separate two unrelated dialogs cannot judge two similar ones.
//!
//! cargo run -p sylpheed-formats --example dialog_pair_identity
use std::path::PathBuf;
use sylpheed_formats::pak::PakArchive;
fn cmp(ar: &PakArchive, a: usize, b: usize, what: &str) {
let (Ok(x), Ok(y)) = (ar.read(&ar.entries()[a]), ar.read(&ar.entries()[b])) else {
println!("{what}: unreadable");
return;
};
let same_len = x.len() == y.len();
let n = x.len().min(y.len());
let diff = (0..n).filter(|&i| x[i] != y[i]).count();
let first = (0..n).find(|&i| x[i] != y[i]);
println!("{what}");
println!(
" sizes {} / {} ({})",
x.len(),
y.len(),
if same_len { "equal" } else { "DIFFER" }
);
println!(
" differing bytes over the common prefix: {diff} / {n} ({:.2}%)",
100.0 * diff as f64 / n as f64
);
match first {
None if same_len => println!(" => BYTE-IDENTICAL — a duplicate"),
None => println!(" => one is a prefix of the other"),
Some(o) => println!(" => first difference at offset 0x{o:X}"),
}
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
cmp(
&ar,
10,
11,
"CONTROL: entries 10/11 — known two different dialogs",
);
cmp(&ar, 0, 1, "entries 0/1");
cmp(&ar, 2, 3, "entries 2/3 — the DIFFICULTY build");
}

View File

@@ -1,58 +0,0 @@
//! Are `GP_DIALOG`'s 140 entries adjacent EN/JP pairs, one per dialog record?
//!
//! The dialog table has 70 records and the archive has 140 entries. If the
//! pairing is adjacent — (0,1), (2,3), … — then dialog index = entry / 2, and the
//! unbound id-to-entry join becomes an ordering question rather than a search.
//!
//! Test: for each pair, compare the SET of element names. GP_TITLE's EN/JP pairs
//! share their sprite sets exactly except for the title art (4/7), so identical
//! sets are the signature of a language pair.
//!
//! cargo run -p sylpheed-formats --example dialog_pairing
use std::collections::BTreeSet;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn names(ar: &PakArchive, i: usize) -> Option<BTreeSet<String>> {
let by = ar.read(&ar.entries()[i]).ok()?;
let b = ui_layout::parse_build(&by)?;
Some(b.elements.iter().map(|e| e.name.clone()).collect())
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let (mut adj_same, mut adj_diff, mut skipped) = (0, 0, 0);
for k in (0..n).step_by(2) {
match (names(&ar, k), names(&ar, k + 1)) {
(Some(a), Some(b)) => {
if a == b {
adj_same += 1;
println!(" identical pair: entries {k}/{}", k + 1)
} else {
adj_diff += 1
}
}
_ => skipped += 1,
}
}
println!("ADJACENT pairing (2k, 2k+1): identical {adj_same} differing {adj_diff} unreadable {skipped}");
// rival hypothesis: halves, (i, i+70)
let (mut h_same, mut h_diff, mut h_skip) = (0, 0, 0);
for k in 0..n / 2 {
match (names(&ar, k), names(&ar, k + n / 2)) {
(Some(a), Some(b)) => {
if a == b {
h_same += 1
} else {
h_diff += 1
}
}
_ => h_skip += 1,
}
}
println!(
"HALVES pairing (i, i+70): identical {h_same} differing {h_diff} unreadable {h_skip}"
);
}

View File

@@ -1,29 +0,0 @@
//! How is `GP_DIALOG.pak` organised? Testing whether dialog id maps positionally.
//!
//! The dialog table gives name -> id (70 records, `DLG_SELECT_DIFFICULTY` = 2000)
//! and the disc gives a unique four-button build at GP_DIALOG entries 2/3. Nothing
//! joins them. If the archive were laid out in table order, or in id order, the
//! join would be positional — this checks.
//!
//! cargo run -p sylpheed-formats --example dialog_pak_shape
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_DIALOG.pak")).expect("GP_DIALOG");
let n = ar.entries().len();
let mut builds = 0;
let mut with_btn = 0;
for e in ar.entries() {
let Ok(by) = ar.read(e) else { continue };
if let Some(b) = ui_layout::parse_build(&by) {
builds += 1;
if b.elements.iter().any(|el| el.name.contains("btn")) {
with_btn += 1;
}
}
}
println!("entries {n}, parse as builds {builds}, of those with a btn element {with_btn}");
println!("dialog table has 70 records; 70 x 2 (EN/JP) = 140");
}

View File

@@ -1,30 +1,13 @@
use sylpheed_formats::{game_data, localization::TextIndex, PakArchive}; use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
fn main() { fn main(){
let disc = std::env::var("SYLPHEED_DISC").unwrap(); let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text = TextIndex::build(&pak); let text=TextIndex::build(&pak);
let msgs = game_data::load_demo_messages(&pak); let msgs=game_data::load_demo_messages(&pak);
println!("{} dialogue lines total\n", msgs.len()); println!("{} dialogue lines total\n", msgs.len());
for m in msgs for m in msgs.iter().filter(|m|m.character.is_some()&&!m.page_keys.is_empty()).take(8){
.iter() let who=m.character.as_deref().unwrap_or("?").trim_start_matches("Character");
.filter(|m| m.character.is_some() && !m.page_keys.is_empty()) let line:String=m.page_keys.iter().filter_map(|k|text.get(k)).collect::<Vec<_>>().join(" ");
.take(8) println!(" {who:10} [{}] “{}", m.voice_clip.as_deref().unwrap_or("-"), line.chars().take(64).collect::<String>());
{
let who = m
.character
.as_deref()
.unwrap_or("?")
.trim_start_matches("Character");
let line: String = m
.page_keys
.iter()
.filter_map(|k| text.get(k))
.collect::<Vec<_>>()
.join(" ");
println!(
" {who:10} [{}] “{}",
m.voice_clip.as_deref().unwrap_or("-"),
line.chars().take(64).collect::<String>()
);
} }
} }

View File

@@ -1,60 +1,29 @@
use std::collections::BTreeMap;
use sylpheed_formats::{idxd::IdxdObject, PakArchive}; use sylpheed_formats::{idxd::IdxdObject, PakArchive};
fn main() { use std::collections::BTreeMap;
let disc = std::env::var("SYLPHEED_DISC").unwrap(); fn main(){
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); let disc=std::env::var("SYLPHEED_DISC").unwrap();
let tables: [(u32, &str); 4] = [ let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
(0x0426e81d, "Player / physics+scoring"), let tables:[(u32,&str);4]=[(0x0426e81d,"Player / physics+scoring"),(0x6ab4825a,"Weapon"),(0x43faa517,"Unit / craft"),(0x3c5b0549,"Vessel / capital ship")];
(0x6ab4825a, "Weapon"), for (want,label) in tables{
(0x43faa517, "Unit / craft"),
(0x3c5b0549, "Vessel / capital ship"),
];
for (want, label) in tables {
// collect all records of this schema // collect all records of this schema
let mut recs: Vec<IdxdObject> = vec![]; let mut recs:Vec<IdxdObject>=vec![];
for e in arc.entries() { for e in arc.entries(){ let Ok(b)=arc.read(e) else{continue}; let Ok(o)=IdxdObject::parse(&b) else{continue}; if o.schema_hash==want{recs.push(o);} }
let Ok(b) = arc.read(e) else { continue };
let Ok(o) = IdxdObject::parse(&b) else {
continue;
};
if o.schema_hash == want {
recs.push(o);
}
}
// field-union (explicit-valued keys), with occurrence count // field-union (explicit-valued keys), with occurrence count
let mut cols: BTreeMap<String, u32> = BTreeMap::new(); let mut cols:BTreeMap<String,u32>=BTreeMap::new();
for o in &recs { for o in &recs{ for (k,_) in o.resolved_fields(){ *cols.entry(k.into()).or_default()+=1; } }
for (k, _) in o.resolved_fields() {
*cols.entry(k.into()).or_default() += 1;
}
}
println!("\n╔══ {label} [{want:08x}] {} records ══", recs.len()); println!("\n╔══ {label} [{want:08x}] {} records ══", recs.len());
let mut cv: Vec<_> = cols.into_iter().collect(); let mut cv:Vec<_>=cols.into_iter().collect(); cv.sort_by_key(|x|std::cmp::Reverse(x.1));
cv.sort_by_key(|x| std::cmp::Reverse(x.1)); let colstr:String=cv.iter().take(28).map(|(k,c)|format!("{k}({c})")).collect::<Vec<_>>().join(" ");
let colstr: String = cv
.iter()
.take(28)
.map(|(k, c)| format!("{k}({c})"))
.collect::<Vec<_>>()
.join(" ");
println!("║ numeric/enum fields: {colstr}"); println!("║ numeric/enum fields: {colstr}");
// dump 2 sample records: identity + explicit fields // dump 2 sample records: identity + explicit fields
for o in recs.iter().take(2) { for o in recs.iter().take(2){
let id = o.get_raw("ID").unwrap_or("?"); let id=o.get_raw("ID").unwrap_or("?");
let name = o.get_raw("Name").unwrap_or(""); let name=o.get_raw("Name").unwrap_or("");
print!("║ • {id}"); print!("║ • {id}");
if !name.is_empty() { if !name.is_empty(){print!(" «{name}»");}
print!(" «{name}»");
}
println!(); println!();
let fields: Vec<String> = o let fields:Vec<String>=o.resolved_fields().iter().map(|(k,v)|format!("{k}={v}")).collect();
.resolved_fields() for chunk in fields.chunks(5){ println!("{}", chunk.join(" ")); }
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
for chunk in fields.chunks(5) {
println!("{}", chunk.join(" "));
}
} }
} }
} }

View File

@@ -1,29 +1,11 @@
use std::collections::BTreeMap;
use sylpheed_formats::{game_data, PakArchive}; use sylpheed_formats::{game_data, PakArchive};
fn main() { use std::collections::BTreeMap;
let disc = std::env::var("SYLPHEED_DISC").unwrap(); fn main(){
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); let disc=std::env::var("SYLPHEED_DISC").unwrap();
let chars = game_data::load_characters(&pak); let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let mut byfac: BTreeMap<String, Vec<String>> = Default::default(); let chars=game_data::load_characters(&pak);
for c in &chars { let mut byfac:BTreeMap<String,Vec<String>>=Default::default();
byfac for c in &chars{ byfac.entry(c.faction.clone().unwrap_or("·".into())).or_default().push(format!("{}({})",c.id.clone().unwrap_or_default().trim_start_matches("Character"),c.faces.len())); }
.entry(c.faction.clone().unwrap_or("·".into())) println!("{} characters across {} factions:",chars.len(),byfac.len());
.or_default() for (f,mut v) in byfac{ v.sort(); println!(" [{f}] {}", v.join(" ")); }
.push(format!(
"{}({})",
c.id.clone()
.unwrap_or_default()
.trim_start_matches("Character"),
c.faces.len()
));
}
println!(
"{} characters across {} factions:",
chars.len(),
byfac.len()
);
for (f, mut v) in byfac {
v.sort();
println!(" [{f}] {}", v.join(" "));
}
} }

View File

@@ -1,83 +1,14 @@
use sylpheed_formats::{idxd::IdxdObject, PakArchive}; use sylpheed_formats::{idxd::IdxdObject, PakArchive};
fn short(s: &str) -> String { fn short(s:&str)->String{ s.trim_start_matches("Weapon_").trim_start_matches("UN_").trim_start_matches("UnitName_UN_").into() }
s.trim_start_matches("Weapon_") fn g<'a>(o:&'a IdxdObject,k:&str)->String{ o.get_f32(k).map(|v|{if v==v.trunc(){format!("{}",v as i64)}else{format!("{v}")}}).or_else(||o.get_raw(k).filter(|x|x.chars().next().map(|c|c.is_ascii_digit()).unwrap_or(false)).map(|s|s.to_string())).unwrap_or("·".into()) }
.trim_start_matches("UN_") fn main(){
.trim_start_matches("UnitName_UN_") let disc=std::env::var("SYLPHEED_DISC").unwrap();
.into() let arc=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
} let recs=|want:u32|->Vec<IdxdObject>{ arc.entries().iter().filter_map(|e|arc.read(e).ok()).filter_map(|b|IdxdObject::parse(&b).ok()).filter(|o|o.schema_hash==want).collect() };
fn g(o: &IdxdObject, k: &str) -> String {
o.get_f32(k)
.map(|v| {
if v == v.trunc() {
format!("{}", v as i64)
} else {
format!("{v}")
}
})
.or_else(|| {
o.get_raw(k)
.filter(|x| {
x.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
})
.map(|s| s.to_string())
})
.unwrap_or("·".into())
}
fn main() {
let disc = std::env::var("SYLPHEED_DISC").unwrap();
let arc = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let recs = |want: u32| -> Vec<IdxdObject> {
arc.entries()
.iter()
.filter_map(|e| arc.read(e).ok())
.filter_map(|b| IdxdObject::parse(&b).ok())
.filter(|o| o.schema_hash == want)
.collect()
};
println!("### WEAPONS (name | target | load | int | power | vel | range | trig)"); println!("### WEAPONS (name | target | load | int | power | vel | range | trig)");
for o in recs(0x6ab4825a).iter().take(16) { for o in recs(0x6ab4825a).iter().take(16){ println!("{:<34}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), o.get_raw("TargetType").unwrap_or("·"), g(o,"LoadingCount"),g(o,"Interval"),g(o,"Power"),g(o,"Velocity"),g(o,"MaximumRange"),g(o,"TriggerShotCount")); }
println!(
"{:<34}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
short(o.get_raw("ID").unwrap_or("?")),
o.get_raw("TargetType").unwrap_or("·"),
g(o, "LoadingCount"),
g(o, "Interval"),
g(o, "Power"),
g(o, "Velocity"),
g(o, "MaximumRange"),
g(o, "TriggerShotCount")
);
}
println!("\n### UNITS/CRAFT (name | HP | cruise | accel | radar | turrets | score)"); println!("\n### UNITS/CRAFT (name | HP | cruise | accel | radar | turrets | score)");
for o in recs(0x43faa517).iter().take(16) { for o in recs(0x43faa517).iter().take(16){ println!("{:<38}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"CruisingVelocity"),g(o,"Acceleration"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"ScorePoint")); }
println!(
"{:<38}\t{}\t{}\t{}\t{}\t{}\t{}",
short(o.get_raw("ID").unwrap_or("?")),
g(o, "HP"),
g(o, "CruisingVelocity"),
g(o, "Acceleration"),
g(o, "RadarRange"),
g(o, "TurretCount"),
g(o, "ScorePoint")
);
}
println!("\n### VESSELS/CAPITAL SHIPS (name | HP | Sz_X | Sz_Z | radar | turrets | bridges | hatches | shieldgen | thrusters)"); println!("\n### VESSELS/CAPITAL SHIPS (name | HP | Sz_X | Sz_Z | radar | turrets | bridges | hatches | shieldgen | thrusters)");
for o in recs(0x3c5b0549).iter() { for o in recs(0x3c5b0549).iter(){ println!("{:<30}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", short(o.get_raw("ID").unwrap_or("?")), g(o,"HP"),g(o,"Size_X"),g(o,"Size_Z"),g(o,"RadarRange"),g(o,"TurretCount"),g(o,"BridgeCount"),g(o,"HatchCount"),g(o,"ShieldGeneratorCount"),g(o,"ThrusterCount")); }
println!(
"{:<30}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
short(o.get_raw("ID").unwrap_or("?")),
g(o, "HP"),
g(o, "Size_X"),
g(o, "Size_Z"),
g(o, "RadarRange"),
g(o, "TurretCount"),
g(o, "BridgeCount"),
g(o, "HatchCount"),
g(o, "ShieldGeneratorCount"),
g(o, "ThrusterCount")
);
}
} }

View File

@@ -1,21 +1,15 @@
use sylpheed_formats::{localization::TextIndex, PakArchive}; use sylpheed_formats::{game_data, localization::TextIndex, PakArchive};
fn main() { fn main(){
let disc = std::env::var("SYLPHEED_DISC").unwrap(); let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap(); let pak=PakArchive::open(format!("{disc}/dat/GP_MAIN_GAME_E.pak")).unwrap();
let text = TextIndex::build(&pak); let text=TextIndex::build(&pak);
for n in 1..=16u32 { for n in 1..=16u32{
let sid = format!("S{n:02}"); let sid=format!("S{n:02}");
// primary objective across phases (first that resolves) // primary objective across phases (first that resolves)
let obj: Vec<String> = (1..=3) let obj:Vec<String>=(1..=3).flat_map(|p|text.objectives(&sid,p)).map(|s|s.to_string()).collect();
.flat_map(|p| text.objectives(&sid, p)) let lose:Vec<String>=(1..=3).flat_map(|p|text.lose_conditions(&sid,p)).map(|s|s.to_string()).collect();
.map(|s| s.to_string()) let full_obj=obj.join(" ");
.collect(); let full_lose=lose.into_iter().take(2).collect::<Vec<_>>().join(" ");
let lose: Vec<String> = (1..=3)
.flat_map(|p| text.lose_conditions(&sid, p))
.map(|s| s.to_string())
.collect();
let full_obj = obj.join(" ");
let full_lose = lose.into_iter().take(2).collect::<Vec<_>>().join(" ");
println!("{sid}\t{full_obj}\t{full_lose}"); println!("{sid}\t{full_obj}\t{full_lose}");
} }
} }

View File

@@ -3,8 +3,8 @@
//! `XBG7_EDGE_CAP` sets the cap; this reports, for one setting, how much //! `XBG7_EDGE_CAP` sets the cap; this reports, for one setting, how much
//! geometry decodes and how self-consistent it is across containers — the two //! geometry decodes and how self-consistent it is across containers — the two
//! numbers any change to the cap has to trade off. Run it once per cap value. //! numbers any change to the cap has to trade off. Run it once per cap value.
use std::collections::BTreeMap;
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
fn main() { fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir"); let dir = std::env::args().nth(1).expect("resource3d dir");
@@ -20,9 +20,7 @@ fn main() {
let mut seen: BTreeMap<String, Vec<([i64; 3], usize, usize)>> = BTreeMap::new(); let mut seen: BTreeMap<String, Vec<([i64; 3], usize, usize)>> = BTreeMap::new();
let (mut models, mut verts) = (0usize, 0usize); let (mut models, mut verts) = (0usize, 0usize);
for f in &files { for f in &files {
let Ok(bytes) = std::fs::read(f) else { let Ok(bytes) = std::fs::read(f) else { continue };
continue;
};
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) { for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes { for s in &m.meshes {
@@ -66,7 +64,7 @@ fn main() {
} }
} }
let (mut shared, mut inconsistent) = (0usize, 0usize); let (mut shared, mut inconsistent) = (0usize, 0usize);
for list in seen.values() { for (_, list) in &seen {
if list.len() < 2 || !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) { if list.len() < 2 || !list.iter().all(|e| e.1 == list[0].1 && e.2 == list[0].2) {
continue; continue;
} }

View File

@@ -1,113 +0,0 @@
//! Every record reachable from an element — leaf **and** `focus_link` — plus a
//! disc-wide census of how many elements have more than one.
//!
//! ⚠️ WHY. I claimed `ptbtn00f`'s peak alpha of 80 was undeclared, having read
//! `ptbtn00.rat` (the leaf, flat 255) and stopped. The pulse is in
//! `ptbtn00f.rat`, the focus record. `focus_link` was already parsed and
//! `ui_layout.rs` already documented it: the format was known and I did not
//! consult it. An absence claim is only as good as its enumeration, so this
//! enumerates rather than asking the reader to remember.
//!
//! cargo run -p sylpheed-formats --example element_records -- GP_TITLE ptbtn00
//! cargo run -p sylpheed-formats --example element_records -- --census
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let argv: Vec<String> = std::env::args().skip(1).collect();
if argv.iter().any(|a| a == "--census") {
let (mut els, mut linked, mut screens_with) = (0usize, 0usize, 0usize);
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
for ent in ar.entries() {
let Ok(by) = ar.read(ent) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let n = b.elements.iter().filter(|e| e.focus_link.is_some()).count();
els += b.elements.len();
linked += n;
if n > 0 {
screens_with += 1
}
}
}
println!("elements disc-wide : {els}");
println!(
"with a focus_link record : {linked} ({:.1}%)",
100.0 * linked as f64 / els as f64
);
println!("builds containing at least 1: {screens_with}");
println!("\nEach of those carries a SECOND record whose keyframes are invisible");
println!("to anyone who looks up the leaf by name and stops.");
return;
}
let pak = argv.first().cloned().unwrap_or_else(|| "GP_TITLE".into());
let want = argv.get(1).cloned();
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
for (i, ent) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(ent) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for el in &b.elements {
if let Some(w) = &want {
if !el.name.starts_with(w.as_str()) {
continue;
}
}
if el.focus_link.is_none() && want.is_none() {
continue;
}
println!("entry {i}: {}", el.name);
let stem = el.name.trim_end_matches(".rat");
for (tag, rec) in [
("leaf", format!("{stem}.rat")),
("focus", el.focus_link.clone().unwrap_or_default()),
] {
if rec.is_empty() {
continue;
}
match b.records.get(rec.as_str()) {
Some(&(lo, ls)) => {
let bytes = &by[lo..(lo + ls).min(by.len())];
let loop_u = ui_layout::loop_length_units(bytes);
let kf: Vec<String> = ui_layout::parse_build(bytes)
.map(|lb| {
lb.elements
.iter()
.map(|e| {
format!(
"{} [{} keys, peak a{}]",
e.name,
e.keyframes.len(),
e.keyframes
.iter()
.map(|k| k.fade >> 24)
.max()
.unwrap_or(0)
)
})
.collect()
})
.unwrap_or_default();
println!(" {tag:<6} {rec:<20} loop {:?} {}", loop_u, kf.join(", "));
}
None => println!(" {tag:<6} {rec:<20} (no such record)"),
}
}
}
}
}

View File

@@ -8,16 +8,13 @@
//! however big, is part of the silhouette. //! however big, is part of the silhouette.
//! //!
//! Usage: envelope_screen <resource3d_dir> [protrusion_fraction] //! Usage: envelope_screen <resource3d_dir> [protrusion_fraction]
use std::collections::{BTreeSet, HashSet};
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::ship::{assemble_ship, ship_id_of}; use sylpheed_formats::ship::{assemble_ship, ship_id_of};
use std::collections::{BTreeSet, HashSet};
fn main() { fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir"); let dir = std::env::args().nth(1).expect("resource3d dir");
let limit: f32 = std::env::args() let limit: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(0.35);
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(0.35);
let mut files: Vec<_> = std::fs::read_dir(&dir) let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap() .unwrap()
.flatten() .flatten()
@@ -28,9 +25,7 @@ fn main() {
let mut flagged = 0usize; let mut flagged = 0usize;
for f in &files { for f in &files {
let Ok(bytes) = std::fs::read(f) else { let Ok(bytes) = std::fs::read(f) else { continue };
continue;
};
let ids: BTreeSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) let ids: BTreeSet<String> = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false)
.iter() .iter()
.filter_map(|m| ship_id_of(&m.name).map(|s| s.to_string())) .filter_map(|m| ship_id_of(&m.name).map(|s| s.to_string()))
@@ -45,9 +40,7 @@ fn main() {
// World box per placement. // World box per placement.
let mut boxes: Vec<(String, [f32; 3], [f32; 3])> = Vec::new(); let mut boxes: Vec<(String, [f32; 3], [f32; 3])> = Vec::new();
for p in &placed { for p in &placed {
let Some(m) = models.iter().find(|m| m.name == p.resource) else { let Some(m) = models.iter().find(|m| m.name == p.resource) else { continue };
continue;
};
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for s in &m.meshes { for s in &m.meshes {
for q in &s.positions { for q in &s.positions {
@@ -109,8 +102,5 @@ fn main() {
} }
} }
} }
println!( println!("{flagged} parts protrude more than {:.0}% of their ship's size", 100.0 * limit);
"{flagged} parts protrude more than {:.0}% of their ship's size",
100.0 * limit
);
} }

View File

@@ -1,48 +0,0 @@
//! Is `ptbtn11` the TOP item of the `EXTRAS` screen?
//!
//! `sylpheed-port` authors `extras/initial_focus: ptbtn11` and states it is
//! correct under the surviving reading — "a submenu resets to the item it opens
//! on". The oracle shows EXTRAS opening on `MISSION SELECT`, the first of
//! MISSION SELECT / MOVIE THEATER / BACK. So their value is right only if
//! `ptbtn11` is that first item. This checks it against the disc.
//!
//! CONTROL: the same read on the MAIN MENU build, whose five buttons have a known
//! top-to-bottom order (NEW GAME first). If the ordering rule cannot reproduce a
//! known screen it cannot be trusted on an unknown one.
//!
//! cargo run -p sylpheed-formats --example extras_button_order
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn report(ar: &PakArchive, entry: usize, what: &str) {
let Ok(by) = ar.read(&ar.entries()[entry]) else {
return;
};
let Some(b) = ui_layout::parse_build(&by) else {
return;
};
let mut rows: Vec<(i32, String, u32)> = b
.elements
.iter()
.filter(|e| e.name.starts_with("ptbtn") && !e.name.contains('f'))
.map(|e| {
let y = e.rest().map(|k| k.y).unwrap_or(e.pivot_y as i32);
(y, e.name.clone(), e.kind)
})
.collect();
rows.sort_by_key(|r| r.0);
println!("\n{what} (entry {entry}) — buttons top to bottom:");
for (y, n, k) in &rows {
println!(" y {y:5} {n:14} kind 0x{k:04x}");
}
if let Some((_, first, _)) = rows.first() {
println!(" => TOP item is {first}");
}
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
report(&ar, 5, "CONTROL: main menu (NEW GAME must be top)");
report(&ar, 6, "EXTRAS");
}

View File

@@ -5,8 +5,8 @@
//! assembler and the viewer) can reach a different answer from a whole-container //! assembler and the viewer) can reach a different answer from a whole-container
//! decode. This measures that directly. //! decode. This measures that directly.
//! Usage: filter_consistency <container.xpr> [resource...] //! Usage: filter_consistency <container.xpr> [resource...]
use std::collections::HashSet;
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
use std::collections::HashSet;
fn main() { fn main() {
let a: Vec<String> = std::env::args().collect(); let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container"); let bytes = std::fs::read(&a[1]).expect("container");
@@ -27,16 +27,9 @@ fn main() {
if off(f) != off(s) { if off(f) != off(s) {
differ += 1; differ += 1;
if differ <= 10 { if differ <= 10 {
println!( println!("{n}: full decode at 0x{:x}, filtered at 0x{:x}", off(f), off(s));
"{n}: full decode at 0x{:x}, filtered at 0x{:x}",
off(f),
off(s)
);
} }
} }
} }
println!( println!("{differ} of {} resources decode differently when filtered", names.len());
"{differ} of {} resources decode differently when filtered",
names.len()
);
} }

View File

@@ -1,69 +0,0 @@
//! Where does the `DIFFICULTY` screen live?
//!
//! `boot-config-and-gamepart-registry.md` records a count-match — "Ⓑ = event 0,
//! four menu items load an external archive, EXTRAS stays inside GP_TITLE" —
//! explicitly as an observation, not a decode. The disc can test half of it:
//! OPTIONS, LOAD GAME and TUTORIAL have their own paks, and EXTRAS' two items
//! have GP_MISSION_SELECT / GP_MOVIE_THEATER while EXTRAS itself is GP_TITLE
//! entries 6/9. NEW GAME is the fourth, and there is no GP_DIFFICULTY.pak.
//!
//! So: which archive holds a build with EASY / NORMAL / HARD buttons?
//!
//! CONTROL: the same scan must find the EXTRAS build in GP_TITLE, whose location
//! is independently known (entries 6/9, buttons ptbtn11/12/13).
//!
//! cargo run -p sylpheed-formats --example find_difficulty_build
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let dat = root.join("dat");
let mut paks: Vec<_> = std::fs::read_dir(&dat)
.expect("dat")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
let mut found_extras = false;
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let btns: Vec<&String> = b
.records
.keys()
.filter(|n| n.starts_with("ptbtn") || n.contains("btn"))
.collect();
if btns.len() != 8 {
continue;
}
let name = p.file_name().unwrap().to_string_lossy();
// CONTROL: the known MAIN MENU build (11 records, so this control is now vacuous) must show up.
if name == "GP_TITLE.pak" && (i == 5 || i == 8) {
found_extras = true;
println!("CONTROL {name} entry {i}: {} button records — the known MAIN MENU build (11 records, so this control is now vacuous)",
btns.len());
}
// any build outside GP_TITLE with a small button set is a candidate
if name != "GP_TITLE.pak" {
let mut names: Vec<String> = btns.iter().map(|s| (*s).clone()).collect();
names.sort();
println!(
" {name:28} entry {i:3} {} buttons {:?}",
btns.len(),
&names[..names.len().min(8)]
);
}
}
}
println!("\ncontrol {} — the known MAIN MENU build (11 records, so this control is now vacuous) was {}found",
if found_extras { "PASSED" } else { "FAILED" },
if found_extras { "" } else { "NOT " });
}

View File

@@ -1,68 +0,0 @@
//! Where is the `DIFFICULTY` screen? Search by NAME, not by structure.
//!
//! A previous pass searched every pak for a build with exactly 8 `btn`-named
//! records, on the assumption that DIFFICULTY's four items (EASY / NORMAL / HARD
//! / BACK) pair with `f` focus variants the way GP_TITLE's screens do. Nothing
//! plausible turned up, and the assumption was mine — recorded as a negative
//! narrower than "not found" (data/gp-title-holds-three-button-screens.txt).
//!
//! This drops the structural assumption and looks for the words instead, across
//! every sprite AND record name in every build on the disc.
//!
//! CONTROL: the same scan must find `ptbtn11` in GP_TITLE — a name whose home is
//! independently known — when asked for it. A name scan that finds nothing
//! proves nothing unless it can find something.
//!
//! cargo run -p sylpheed-formats --example find_difficulty_names
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const WANTED: &[&str] = &["easy", "normal", "hard", "diff", "level", "rank"];
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
let mut control = false;
let mut hits = 0usize;
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
let pname = p.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let all: Vec<String> = b.sprites.keys().chain(b.records.keys()).cloned().collect();
if pname == "GP_TITLE.pak" && all.iter().any(|n| n.contains("ptbtn11")) {
control = true;
}
let m: Vec<&String> = all
.iter()
.filter(|n| {
let l = n.to_lowercase();
WANTED.iter().any(|w| l.contains(w))
})
.collect();
if !m.is_empty() {
hits += 1;
let mut s: Vec<String> = m.iter().map(|x| (*x).clone()).collect();
s.sort();
s.dedup();
println!(" {pname:28} entry {i:3} {:?}", &s[..s.len().min(6)]);
}
}
}
println!(
"\ncontrol (found ptbtn11 in GP_TITLE): {}",
if control { "PASSED" } else { "FAILED" }
);
println!("{hits} build(s) carried a difficulty-ish name");
}

View File

@@ -7,8 +7,8 @@
//! mirrored pair. //! mirrored pair.
//! //!
//! Usage: find_mirror <container.xpr> <resource>... //! Usage: find_mirror <container.xpr> <resource>...
use std::collections::HashSet;
use sylpheed_formats::mesh::Xbg7Model; use sylpheed_formats::mesh::Xbg7Model;
use std::collections::HashSet;
fn main() { fn main() {
let a: Vec<String> = std::env::args().collect(); let a: Vec<String> = std::env::args().collect();
@@ -18,12 +18,7 @@ fn main() {
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()); let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
for m in &models { for m in &models {
let pos: Vec<[f32; 3]> = m let pos: Vec<[f32; 3]> = m.meshes.iter().flat_map(|s| s.positions.clone()).take(8).collect();
.meshes
.iter()
.flat_map(|s| s.positions.clone())
.take(8)
.collect();
if pos.len() < 8 { if pos.len() < 8 {
continue; continue;
} }

View File

@@ -1,125 +0,0 @@
//! Which cue owns an XMA stream of a given payload size?
//!
//! A boot with `--xma_param_probe` logs each decoded stream's `byte_size`. Three
//! of the five on the take-2 `ADV` boot are that movie's own streams; two —
//! 1 150 976 and 1 269 760 B — belong to something unidentified. The probe gives
//! a size and nothing else, so the disc has to be asked which cue has a stream
//! that long.
//!
//! Searches every inter-descriptor span of the continuous voice stream, and
//! every `sound.pak` entry, for a stream whose payload matches.
//!
//! cargo run -p sylpheed-formats --example find_stream_by_size -- <disc> <bytes>…
use sylpheed_formats::media::{DirectorySource, DiscSource};
use sylpheed_formats::{slb, PakArchive};
const DESC_MARK: u32 = 0x11;
const DESC_REPEAT: usize = 0x800;
const ID_MAX: u32 = 0x1_0000;
fn all_descriptors(buf: &[u8]) -> Vec<(usize, u32)> {
let be = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]);
let mut out = Vec::new();
if buf.len() < DESC_REPEAT + 8 {
return out;
}
let end = buf.len() - (DESC_REPEAT + 4);
let mut o = 0;
while o <= end {
let id = be(o);
if (1..ID_MAX).contains(&id) && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
out.push((o, id));
}
o += 4;
}
out
}
fn main() {
let mut args = std::env::args().skip(1);
let disc = args
.next()
.expect("usage: find_stream_by_size <disc> <bytes>…");
let wanted: Vec<usize> = args.filter_map(|a| a.parse().ok()).collect();
assert!(!wanted.is_empty(), "give at least one payload size");
// A `to_xma_riffs` chunk is the payload plus a 60-byte RIFF wrapper.
let want_riff: Vec<usize> = wanted.iter().map(|w| w + 60).collect();
println!("looking for payloads {wanted:?} (riff sizes {want_riff:?})\n");
let src = DirectorySource::new(std::path::PathBuf::from(&disc));
// --- 1. the continuous movie-voice stream, span by span
let tpak = src.open_pak("dat/tables.pak").expect("tables.pak");
let marker = "eng\\Movie\\VOICE_ADV.slb";
let registry = tpak
.entries()
.iter()
.find_map(|e| {
tpak.read(e)
.ok()
.filter(|b| b.windows(marker.len()).any(|w| w == marker.as_bytes()))
})
.expect("registry");
let ids = sylpheed_formats::movie_voice::registry_voice_ids(&registry);
let name_of: std::collections::HashMap<u32, String> =
ids.iter().map(|(n, &i)| (i, n.clone())).collect();
let win_start: u64 = 421_739_888 & !3;
let buf = src
.read_segment_range("dat/sound", win_start, 116_300_000)
.expect("window");
let descs = all_descriptors(&buf);
println!("voice stream: {} descriptors", descs.len());
let mut hits = 0;
for w in descs.windows(2) {
let (a, b) = (w[0].0, w[1].0);
if b <= a || b - a < 4096 {
continue;
}
for (i, r) in slb::to_xma_riffs(&buf[a..b]).iter().enumerate() {
if want_riff.contains(&r.len()) {
let name = name_of
.get(&w[1].1)
.cloned()
.unwrap_or_else(|| format!("id{}", w[1].1));
println!(
" ✅ cue {name} (id {}) stream {i}: payload {} B",
w[1].1,
r.len() - 60
);
hits += 1;
}
}
}
println!(" {hits} hit(s) in the voice stream\n");
// --- 2. every sound.pak entry
let stoc = src.read_file("dat/sound.pak").expect("sound.pak toc");
let entries = PakArchive::parse_toc(&stoc).expect("toc");
println!("sound.pak: {} entries", entries.len());
let mut phits = 0;
let mut scanned = 0usize;
for e in &entries {
// Only entries big enough to hold the target.
let need = wanted.iter().copied().min().unwrap_or(0) as u32;
if e.comp_size < need {
continue;
}
let Ok(bytes) = src.read_segment_range("dat/sound", e.offset as u64, e.comp_size as usize)
else {
continue;
};
scanned += 1;
for (i, r) in slb::to_xma_riffs(&bytes).iter().enumerate() {
if want_riff.contains(&r.len()) {
println!(
" ✅ sound.pak entry hash {:08x} offset {} size {} — stream {i}: payload {} B",
e.name_hash,
e.offset,
e.comp_size,
r.len() - 60
);
phits += 1;
}
}
}
println!(" scanned {scanned} entries large enough; {phits} hit(s)");
}

View File

@@ -1,22 +1,16 @@
use sylpheed_formats::{game_data as gd, PakArchive}; use sylpheed_formats::{game_data as gd, PakArchive};
fn main() { fn main(){
let disc = std::env::var("SYLPHEED_DISC").unwrap(); let disc=std::env::var("SYLPHEED_DISC").unwrap();
let pak = PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap(); let pak=PakArchive::open(format!("{disc}/dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let rosters = gd::load_pilot_rosters(&pak); let rosters=gd::load_pilot_rosters(&pak);
// distinct rosters by their pilot set // distinct rosters by their pilot set
let mut seen = std::collections::BTreeSet::new(); let mut seen=std::collections::BTreeSet::new(); let mut shown=0;
let mut shown = 0;
println!("{} pilot-roster configs; distinct line-ups:", rosters.len()); println!("{} pilot-roster configs; distinct line-ups:", rosters.len());
for r in &rosters { for r in &rosters{
let key: String = r let key:String=r.pilots().iter().map(|(c,p)|format!("{c}:{p}")).collect::<Vec<_>>().join(",");
.pilots() if seen.insert(key) && shown<8 {
.iter() shown+=1;
.map(|(c, p)| format!("{c}:{p}")) let flt:Vec<String>=r.pilots().iter().map(|(c,p)|format!("{c}={p}")).collect();
.collect::<Vec<_>>()
.join(",");
if seen.insert(key) && shown < 8 {
shown += 1;
let flt: Vec<String> = r.pilots().iter().map(|(c, p)| format!("{c}={p}")).collect();
println!(" {}", flt.join(" ")); println!(" {}", flt.join(" "));
} }
} }

View File

@@ -1,98 +0,0 @@
//! Which focus records have a VARYING alpha — disc-wide, not export-wide?
//!
//! `rest()` returns an element's last hold keyframe. For a constant-alpha
//! element that is harmless. For one that pulses it returns the PEAK, which is
//! the `ui-settle-time.md` pathology: the plate's `ptbtn00f` ramps 0→80→0 and
//! `rest()` reports 80, its maximum.
//!
//! The port censused this over its own export (34 records, 2 varying) and
//! concluded there is nothing to fix. That conclusion is only as wide as the
//! export. This asks the same question of the whole disc.
use sylpheed_formats::{pak, ratc, ui_layout};
fn main() {
let root =
std::env::var("SYLPHEED_DISC").expect("set SYLPHEED_DISC to the extracted disc root");
let mut paks: Vec<_> = std::fs::read_dir(format!("{root}/dat"))
.expect("dat/")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("pak"))
.collect();
paks.sort();
let (mut n_rec, mut n_elem, mut varying) = (0usize, 0usize, 0usize);
let (mut at_peak, mut mid_ramp) = (0usize, 0usize);
let mut by_pak: std::collections::BTreeMap<String, usize> = Default::default();
let mut hits: Vec<String> = Vec::new();
for p in &paks {
let pn = p.file_name().unwrap().to_string_lossy().to_string();
let Ok(ar) = pak::PakArchive::open(p) else {
continue;
};
for (ei, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
if !ratc::is_ratc(&by) {
continue;
}
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for (rn, &(o, s)) in &b.records {
// A focus record is one whose name is another record's plus `f`.
let Some(stem) = rn.strip_suffix("f.rat") else {
continue;
};
if !b.records.contains_key(&format!("{stem}.rat")) {
continue;
}
if o + s > by.len() {
continue;
}
let Some(lb) = ui_layout::parse_build(&by[o..o + s]) else {
continue;
};
n_rec += 1;
for el in &lb.elements {
if el.keyframes.is_empty() {
continue;
}
n_elem += 1;
let a: Vec<u32> = el.keyframes.iter().map(|k| k.fade >> 24).collect();
let (lo, hi) = (*a.iter().min().unwrap(), *a.iter().max().unwrap());
if lo == hi {
continue;
}
varying += 1;
let rest = el.rest().map(|k| k.fade >> 24).unwrap_or(0);
if rest == hi {
at_peak += 1
} else {
mid_ramp += 1
}
*by_pak.entry(pn.clone()).or_default() += 1;
hits.push(format!(
"{pn} [{ei}] {rn}::{} alpha {lo}..{hi} rest()={rest}{}",
el.name,
if rest == hi { " 🔴 == PEAK" } else { "" }
));
}
}
}
}
println!("focus records disc-wide : {n_rec}");
println!(" their timed elements : {n_elem}");
println!(" with a VARYING alpha : {varying}");
println!(" of which rest() == the PEAK : {at_peak} <- burns bright forever");
println!(" of which rest() is MID-RAMP : {mid_ramp} <- neither extreme; looks plausible");
println!("\nby pak:");
for (k, v) in &by_pak {
println!(" {k:<34} {v}")
}
println!("\nevery varying one:");
hits.sort();
hits.dedup();
for h in &hits {
println!(" {h}")
}
println!("\n({} distinct)", hits.len());
}

View File

@@ -1,99 +0,0 @@
//! Reconcile two ink counts for one screen that were never counting the same pixels.
//!
//! The port agent double-witnessed the pixel-cost claim in Godot — a renderer
//! sharing no code with `compose` — and got `GP_TITLE` entry 12 at **59 530 px**
//! ink above threshold 0 and **48 368** above 1. This crate reported **49 771**.
//! Neither is wrong; the question is which convention each was using, and on a
//! mostly-dark frame the answer moves thousands of pixels.
//!
//! So: count the same composite every way, and print the family. Whichever row
//! the port's numbers land in is the convention, and then the two renderers can be
//! compared on purpose rather than by coincidence.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_ink_thresholds
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
use ui_layout::ComposeOptions;
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
let el = &build.elements[i];
(
ui_layout::sprite_layer_key(build, bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX),
i,
)
});
idx
}
fn counts(rgba: &[u8], t: u8) -> (usize, usize) {
let rgb = rgba
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[0] > t || p[1] > t || p[2] > t)
.count();
let alpha = rgba.as_chunks::<4>().0.iter().filter(|p| p[3] > t).count();
(rgb, alpha)
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for entry in [12usize, 15] {
let by = ar.read(&ar.entries()[entry]).expect("entry");
let b = ui_layout::parse_build(&by).expect("parse");
for (label, opts) in [
(
"primitives on (what the cost run used)",
ComposeOptions {
include_primitives: true,
backdrop: [0, 0, 0, 255],
..Default::default()
},
),
(
"primitives+focus+animated",
ComposeOptions {
include_primitives: true,
include_focus: true,
include_animated: true,
backdrop: [0, 0, 0, 255],
..Default::default()
},
),
] {
let with = ui_layout::derived_paint_order(&b, &by);
let without = order_without_rule(&b, &by);
let a = ui_layout::compose_with_order(&b, &by, opts, None, Some(&with));
let c = ui_layout::compose_with_order(&b, &by, opts, None, Some(&without));
println!(
"\n== GP_TITLE entry {entry}{label} ({}x{})",
a.width, a.height
);
println!(
" threshold | RGB>t with rule | A>t with rule | RGB>t WITHOUT | A>t WITHOUT"
);
for t in [0u8, 1, 2, 4, 8, 16] {
let (r1, a1) = counts(&a.rgba, t);
let (r0, a0) = counts(&c.rgba, t);
println!(" >{t:<8} | {r1:>16} | {a1:>14} | {r0:>13} | {a0:>11}");
}
let changed = a
.rgba
.as_chunks::<4>()
.0
.iter()
.zip(c.rgba.as_chunks::<4>().0.iter())
.filter(|(x, y)| x != y)
.count();
println!(" exact-RGBA changed pixels between the two orders: {changed}");
}
}
}

View File

@@ -1,73 +0,0 @@
//! Of the forced instances the rule merely CONFIRMS, how many have a key READ
//! FROM THE FILE, and how many an IMPLIED key that is itself a measurement?
//!
//! `forced_backdrop_necessity.rs` asked only whether an element had *a* key,
//! collapsing `sprite_layer_key` (a `u16` read out of the `T8aD` header — decoded)
//! with `implied_layer_key` (this crate's per-name table of positions **measured
//! in the running game**). For counting whether the rule moves anything that is
//! the right question. For describing what a confirmation is *made of*, it is not:
//! "the file already settles it" and "another measurement already settles it" are
//! different claims, and a reader who sees "own key" will take the first.
//!
//! Raised by the port agent 2026-08-30.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_key_source
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
paks.sort();
let (mut read, mut implied, mut none) = (0usize, 0usize, 0usize);
println!("# archive entry element key_source key");
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
let name = pak.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
for el in &b.elements {
if !ui_layout::forced_backdrop(&b, el) {
continue;
}
let (src, key) = match ui_layout::sprite_layer_key(&b, &by, el) {
Some(k) => {
read += 1;
("read_T8aD", Some(k))
}
None => match ui_layout::implied_layer_key(&el.name) {
Some(k) => {
implied += 1;
("implied_MEASURED", Some(k))
}
None => {
none += 1;
("none", None)
}
},
};
println!(
" {name} {i} {} {src} {}",
el.name,
key.map(|k| format!("0x{k:08X}")).unwrap_or("-".into())
);
}
}
}
println!("\n# forced instances by key source:");
println!("# read from the T8aD header (decoded): {read}");
println!("# implied — this crate's MEASURED name table: {implied}");
println!("# none — only forced_backdrop can speak: {none}");
}

View File

@@ -1,117 +0,0 @@
//! Which screens does `forced_backdrop` DECIDE, and which does it merely agree with?
//!
//! Every check this corpus has run on the rule measured its **stability** — that
//! no verdict moved when something else changed. That is a different property
//! from **necessity**: an element whose position is already fixed by a read or an
//! implied key is confirmed by the rule, not decided by it.
//!
//! So: compute `derived_paint_order` with the rule, and again with the
//! `forced_backdrop` fallback removed, and report every entry whose order moves.
//! Where nothing moves, the rule is decorative on that screen; where it moves,
//! the rule is the only thing holding the order up.
//!
//! Raised by the port agent 2026-08-30. Reach note in
//! `docs/re/structures/ui-forced-backdrop.md`.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_necessity -- [pak...]
//!
//! 🔴 With no argument this used to default to `GP_TITLE` alone, so a bare run
//! reported **6 instances, not 80** — a thirteenth of the census, printed in the
//! same format and reading like the whole thing. The port agent hit it and nearly
//! filed the discrepancy back at me. It now walks every `dat/*.pak` by default and
//! says on stderr how many archives it opened, because "I ran your instrument" has
//! to mean the same thing to both of us.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
let el = &build.elements[i];
(
ui_layout::sprite_layer_key(build, bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX),
i,
)
});
idx
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let (mut total_decides, mut total_agrees) = (0usize, 0usize);
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if paks.is_empty() {
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
all.sort();
paks = all;
}
eprintln!("# scanning {} archive(s)", paks.len());
for path in &paks {
let ar = PakArchive::open(path).expect("pak");
println!("# {}", path.display());
println!("# entry forced decides elements note");
let mut decides = Vec::new();
let mut agrees = Vec::new();
#[allow(unused)]
let _ = (&decides, &agrees);
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let with = ui_layout::derived_paint_order(&b, &by);
let without = order_without_rule(&b, &by);
// Which elements does the rule fire on, and of those, which have no key
// of their own to fall back on?
let mut forced = Vec::new();
let mut keyless = Vec::new();
for el in &b.elements {
if !ui_layout::forced_backdrop(&b, el) {
continue;
}
forced.push(el.name.clone());
let own = ui_layout::sprite_layer_key(&b, &by, el)
.or_else(|| ui_layout::implied_layer_key(&el.name));
if own.is_none() {
keyless.push(el.name.clone());
}
}
if forced.is_empty() {
continue;
}
let moved = with != without;
if moved {
decides.push(i);
} else {
agrees.push(i);
}
println!(
" {i:>5} {:>6} {:>7} {:>8} forced=[{}] keyless=[{}]",
forced.len(),
if moved { "YES" } else { "no" },
b.elements.len(),
forced.join(","),
keyless.join(","),
);
}
println!("# rule DECIDES the order on entries {decides:?}");
println!("# rule merely AGREES on entries {agrees:?}\n");
total_decides += decides.len();
total_agrees += agrees.len();
}
println!(
"# TOTAL over {} archive(s): {total_decides} deciding entries, \
{total_agrees} agreeing",
paks.len()
);
}

View File

@@ -1,125 +0,0 @@
//! What does `forced_backdrop` cost IN PIXELS on the screens it decides?
//!
//! `forced_backdrop_necessity.rs` answers "does the derived ORDER move", which is
//! a property of the sort. The port agent then pointed out — correctly — that its
//! re-run of that probe was **my code executed twice**, not a second witness, so
//! the disc-wide 62 has one measurement behind it and only `GP_TITLE` has two.
//!
//! This does not fix that (it is still this crate), but it moves the question to a
//! **different layer**: render each deciding build twice, once in the order
//! `compose` derives and once with the `forced_backdrop` fallback removed, and
//! count the pixels that differ. "The order moved" and "the picture moved" are not
//! the same claim, and the second is the one anybody cares about — the tie-break
//! work already found overlapping reorders that cost exactly zero pixels.
//!
//! Each entry carries its own CONTROL: the pixel count of the composite itself.
//! If a build renders empty, its zero means the instrument saw nothing, not that
//! the rule is free.
//!
//! cargo run -p sylpheed-formats --example forced_backdrop_pixel_cost -- [pak...]
//!
//! With no argument it walks **every `dat/*.pak`** — the necessity probe defaulted
//! to `GP_TITLE`, which made a bare run report a thirteenth of the census and read
//! like the whole thing.
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
use ui_layout::ComposeOptions;
fn order_without_rule(build: &ui_layout::UiBuild, bundle: &[u8]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
idx.sort_by_key(|&i| {
let el = &build.elements[i];
(
ui_layout::sprite_layer_key(build, bundle, el)
.or_else(|| ui_layout::implied_layer_key(&el.name))
.unwrap_or(u32::MAX),
i,
)
});
idx
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
if paks.is_empty() {
let mut all: Vec<PathBuf> = std::fs::read_dir(root.join("dat"))
.expect("dat/")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "pak"))
.collect();
all.sort();
paks = all;
}
eprintln!("# scanning {} archive(s)", paks.len());
let opts = ComposeOptions {
include_primitives: true,
backdrop: [0, 0, 0, 255],
..Default::default()
};
println!("# archive entry element changed_px total_px ink_px(control) pct");
let (mut decided, mut zero_cost, mut blind) = (0usize, 0usize, 0usize);
for pak in &paks {
let Ok(ar) = PakArchive::open(pak) else {
continue;
};
let name = pak.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let with = ui_layout::derived_paint_order(&b, &by);
let without = order_without_rule(&b, &by);
if with == without {
continue;
}
let forced: Vec<&str> = b
.elements
.iter()
.filter(|el| ui_layout::forced_backdrop(&b, el))
.map(|el| el.name.as_str())
.collect();
let a = ui_layout::compose_with_order(&b, &by, opts, None, Some(&with));
let c = ui_layout::compose_with_order(&b, &by, opts, None, Some(&without));
let n = a
.rgba
.as_chunks::<4>()
.0
.iter()
.zip(c.rgba.as_chunks::<4>().0.iter())
.filter(|(x, y)| x != y)
.count();
// Control: does this build put any ink down at all, against the bare
// backdrop? A build that renders to nothing cannot show a reorder.
let ink = a
.rgba
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[..3] != [0, 0, 0])
.count();
let total = a.rgba.len() / 4;
decided += 1;
if ink == 0 {
blind += 1;
} else if n == 0 {
zero_cost += 1;
}
println!(
" {name} {i} {} {n} {total} {ink} {:.2}%",
forced.join(","),
100.0 * n as f64 / total as f64
);
}
}
println!("\n# builds whose ORDER the rule decides: {decided}");
println!("# of those, costing ZERO pixels: {zero_cost}");
println!("# of those, BLIND (build renders no ink, control fails): {blind}");
}

View File

@@ -1,67 +0,0 @@
//! Is `GP_DIALOG` 2/3 the ONLY build on the disc with four buttons at 259/329/399/469?
//!
//! Both agents recorded the same reach on the DIFFICULTY identification: entries
//! 2/3 are picked out by button count and geometry, not by a binding from
//! `DLG_SELECT_DIFFICULTY` to a pak entry, so "another four-button dialog with the
//! same rows would be indistinguishable". This tests whether such a rival exists.
//!
//! CONTROL: the scan must find GP_DIALOG 2 and 3 themselves. A rival-search that
//! cannot find the incumbent proves nothing.
//!
//! cargo run -p sylpheed-formats --example four_button_row_rivals
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
const WANT: [i32; 4] = [259, 329, 399, 469];
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let mut paks: Vec<_> = std::fs::read_dir(root.join("dat"))
.expect("dat")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().map(|x| x == "pak").unwrap_or(false))
.collect();
paks.sort();
let (mut incumbent, mut rivals) = (0, 0);
for p in &paks {
let Ok(ar) = PakArchive::open(p) else {
continue;
};
let pname = p.file_name().unwrap().to_string_lossy().to_string();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let mut ys: Vec<i32> = b
.elements
.iter()
.filter(|el| el.name.contains("btn") && !el.name.contains('f'))
.map(|el| el.rest().map(|k| k.y).unwrap_or(el.pivot_y as i32))
.collect();
ys.sort();
ys.dedup();
if ys.len() != 4 {
continue;
}
let close = ys.iter().zip(WANT.iter()).all(|(a, b)| (a - b).abs() <= 6);
if close {
let is_inc = pname == "GP_DIALOG.pak" && (i == 2 || i == 3);
if is_inc {
incumbent += 1
} else {
rivals += 1
}
println!(
" {}{pname:24} entry {i:4} rows {ys:?}",
if is_inc { "INCUMBENT " } else { "RIVAL " }
);
}
}
}
println!(
"\ncontrol: found {incumbent} incumbent build(s) (want 2) — {}",
if incumbent == 2 { "PASSED" } else { "FAILED" }
);
println!("{rivals} rival build(s) elsewhere on the disc");
}

View File

@@ -1,83 +0,0 @@
//! The main menu's sprites, by their ALPHA channel — is `ptframe1`/`ptframe2`'s
//! "no fully-opaque pixel" a property of the artwork, and does any alpha value
//! look like a scale the game expands (e.g. 0..128) rather than 0..255?
//!
//! The port measures both frames as rendering too DARK against the capture, with
//! the shortfall correlating with the BACKGROUND. Two different causes predict
//! that: a background-scaling blend selected in code, or an alpha that is too
//! LOW in our decode. This example tests the second, which is on the disc.
//!
//! cargo run -p sylpheed-formats --example frame_alpha_census
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, t8ad, ui_layout};
fn census(name: &str, img: &t8ad::T8adImage) {
let n = (img.width * img.height) as usize;
let mut hist = [0usize; 256];
for p in 0..n {
hist[img.rgba[p * 4 + 3] as usize] += 1;
}
let zero = hist[0];
let full = hist[255];
let max = (0..256).rev().find(|&a| hist[a] > 0).unwrap_or(0);
let nonzero = n - zero;
// the top five alpha values that actually occur, by population
let mut top: Vec<(usize, usize)> = (1..256)
.map(|a| (hist[a], a))
.filter(|&(c, _)| c > 0)
.collect();
top.sort_unstable_by_key(|a| std::cmp::Reverse(a.0));
let top5: Vec<String> = top
.iter()
.take(5)
.map(|&(c, a)| format!("{a}x{c}"))
.collect();
println!(
"{name:<16} {}x{:<4} px={n:<8} a=0:{:5.1}% a=255:{:5.1}% max={max:<3} \
partial(1..254)/nonzero={:5.1}% top:[{}]",
img.width,
img.height,
100.0 * zero as f64 / n as f64,
100.0 * full as f64 / n as f64,
if nonzero > 0 {
100.0 * (nonzero - full) as f64 / nonzero as f64
} else {
0.0
},
top5.join(" ")
);
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let argv: Vec<String> = std::env::args().skip(1).collect();
let pak = argv
.iter()
.find(|a| a.parse::<usize>().is_err())
.cloned()
.unwrap_or_else(|| "GP_TITLE".to_string());
let ar = PakArchive::open(root.join(format!("dat/{pak}.pak"))).expect("pak");
// Builds default to the two the port ships and can be overridden, so the
// same census serves the title (4) and the `PRESS (A)` plate (2).
let args: Vec<usize> = argv.iter().filter_map(|a| a.parse().ok()).collect();
let builds: Vec<usize> = if args.is_empty() { vec![5, 6] } else { args };
for build in builds {
let Ok(by) = ar.read(&ar.entries()[build]) else {
continue;
};
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
println!("=== {pak} build {build} ===");
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
let (off, size) = b.sprites[n];
let s = &by[off..(off + size).min(by.len())];
match t8ad::parse(s) {
Some(img) => census(n, &img),
None => println!("{n:<16} (not a T8aD / failed to parse)"),
}
}
}
}

View File

@@ -1,79 +0,0 @@
//! The keyframe record's two unexplained words (`+4`, `+8`) and the fade/tint —
//! do any of them separate the four elements the port measures as rendering too
//! dark (`ptframe1`/`2`, `ptframe3`/`4`) from the ones it measures as accurate?
//!
//! The T8aD header does not: no word and no bit of `+0x04`/`+0x08` puts the four
//! frames on one side and `pteff10` (max alpha 130, wholly semi-transparent, and
//! rendered nearly exact) on the other. The keyframe is the other place a
//! per-element draw mode could live.
//!
//! cargo run -p sylpheed-formats --example frame_keyframe_unknowns
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
println!(
"{:<22} {:>5} {:>3} {:>10} {:>10} {:>8} {:>8} {:>8}",
"element", "build", "kf", "unknown_4", "unknown_8", "fade", "tint", "rot"
);
let mut frame_sets: Vec<(String, i32, i32, u32, u32, i32)> = Vec::new();
let mut other_sets: Vec<(String, i32, i32, u32, u32, i32)> = Vec::new();
for build in [5usize, 6] {
let by = ar.read(&ar.entries()[build]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
for e in &b.elements {
for (i, k) in e.keyframes.iter().enumerate() {
println!(
"{:<22} {build:>5} {i:>3} {:>10} {:>10} {:08X} {:08X} {:>8}",
e.name, k.unknown_4, k.unknown_8, k.fade, k.tint, k.rotation_deg
);
let row = (
e.name.clone(),
k.unknown_4,
k.unknown_8,
k.fade,
k.tint,
k.rotation_deg,
);
if e.name.contains("frame") {
frame_sets.push(row)
} else {
other_sets.push(row)
}
}
}
}
println!(
"\nframe keyframes: {} other keyframes: {}",
frame_sets.len(),
other_sets.len()
);
for (label, get) in [
("unknown_4", 0usize),
("unknown_8", 1),
("fade", 2),
("tint", 3),
("rotation", 4),
] {
let val = |r: &(String, i32, i32, u32, u32, i32)| -> i64 {
match get {
0 => r.1 as i64,
1 => r.2 as i64,
2 => r.3 as i64,
3 => r.4 as i64,
_ => r.5 as i64,
}
};
let fv: std::collections::BTreeSet<i64> = frame_sets.iter().map(val).collect();
let ov: std::collections::BTreeSet<i64> = other_sets.iter().map(val).collect();
let only_frames: Vec<&i64> = fv.iter().filter(|v| !ov.contains(v)).collect();
println!(
"{label:<10} frames take {:?} others take {} distinct values; frame-only values: {:?}",
fv,
ov.len(),
only_frames
);
}
}

View File

@@ -1,96 +0,0 @@
//! Which T8aD header word, if any, separates the FOUR elements the port measures
//! as rendering too dark (`ptframe1`/`2` on the main menu, `ptframe3`/`4` on
//! `EXTRAS`) from the elements on the same two screens it measures as accurate?
//!
//! The control that matters: `pteff10` has max alpha 130 and no fully-opaque
//! pixel — the same "wholly semi-transparent" property the port proposed as the
//! reason the frames are special — and it renders nearly exact. So the separator
//! must put `pteff10` on the ACCURATE side.
//!
//! cargo run -p sylpheed-formats --example frame_vs_accurate_words
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE");
let mut rows: Vec<(String, usize, Vec<u32>)> = Vec::new();
for build in [5usize, 6] {
let by = ar.read(&ar.entries()[build]).expect("entry");
let b = ui_layout::parse_build(&by).expect("build");
let mut names: Vec<&String> = b.sprites.keys().collect();
names.sort();
for n in names {
let (off, size) = b.sprites[n];
let s = &by[off..(off + size).min(by.len())];
if s.len() < 48 || &s[0..4] != b"T8aD" {
continue;
}
let ws: Vec<u32> = (0..12)
.map(|k| u32::from_be_bytes([s[k * 4], s[k * 4 + 1], s[k * 4 + 2], s[k * 4 + 3]]))
.collect();
rows.push((n.clone(), build, ws));
}
}
let is_frame = |n: &str| n.starts_with("ptframe");
println!(
"{:<16} {:>5} +0x04 +0x08 +0x1C +0x2C",
"sprite", "build"
);
for (n, b, w) in &rows {
println!(
"{n:<16} {b:>5} {:08X} {:08X} {:08X} {:08X}{}",
w[1],
w[2],
w[7],
w[11],
if is_frame(n) { " <- TOO DARK" } else { "" }
);
}
println!("\nwords where every ptframe* agrees and NO other sprite takes that value:");
let frames: Vec<&(String, usize, Vec<u32>)> =
rows.iter().filter(|(n, _, _)| is_frame(n)).collect();
let mut any = false;
for k in 0..12 {
let v = frames[0].2[k];
if !frames.iter().all(|r| r.2[k] == v) {
continue;
}
if rows.iter().any(|(n, _, w)| !is_frame(n) && w[k] == v) {
continue;
}
println!(" word {k} (+0x{:02X}) = {v:08X}", k * 4);
any = true;
}
if !any {
println!(" NONE — no header word separates the four frames from the rest");
}
println!(
"\nper-bit check on +0x04 and +0x08 (a bit that is 1 on all frames, 0 on all others):"
);
let mut anyb = false;
for &k in &[1usize, 2] {
for bit in 0..32 {
let on = |v: u32| (v >> bit) & 1 == 1;
if frames.iter().all(|r| on(r.2[k]))
&& rows.iter().all(|(n, _, w)| is_frame(n) || !on(w[k]))
{
println!(" +0x{:02X} bit {bit} (0x{:X})", k * 4, 1u32 << bit);
anyb = true;
}
if frames.iter().all(|r| !on(r.2[k]))
&& rows.iter().all(|(n, _, w)| is_frame(n) || on(w[k]))
{
println!(
" +0x{:02X} bit {bit} (0x{:X}) INVERTED",
k * 4,
1u32 << bit
);
anyb = true;
}
}
}
if !anyb {
println!(" NONE");
}
}

View File

@@ -1,14 +1,11 @@
//! Which gate stops the resources that never decode? //! Which gate stops the resources that never decode?
//! Usage: gate_histogram <resource3d_dir> [max_resources] //! Usage: gate_histogram <resource3d_dir> [max_resources]
use std::collections::{BTreeMap, HashSet};
use sylpheed_formats::mesh::{debug_best_rejection, xbg7_resource_names, Xbg7Model}; use sylpheed_formats::mesh::{debug_best_rejection, xbg7_resource_names, Xbg7Model};
use std::collections::{BTreeMap, HashSet};
fn main() { fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir"); let dir = std::env::args().nth(1).expect("resource3d dir");
let cap: usize = std::env::args() let cap: usize = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(usize::MAX);
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(usize::MAX);
let mut files: Vec<_> = std::fs::read_dir(&dir) let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap() .unwrap()
.flatten() .flatten()
@@ -23,9 +20,7 @@ fn main() {
if done >= cap { if done >= cap {
break; break;
} }
let Ok(bytes) = std::fs::read(f) else { let Ok(bytes) = std::fs::read(f) else { continue };
continue;
};
let names = xbg7_resource_names(&bytes); let names = xbg7_resource_names(&bytes);
if names.is_empty() { if names.is_empty() {
continue; continue;

View File

@@ -1,32 +0,0 @@
//! Every button record in `GP_TITLE.pak`, per entry.
//!
//! Testing half of the count-match in boot-config-and-gamepart-registry.md:
//! "four menu items load an external archive, EXTRAS stays inside GP_TITLE".
//! If DIFFICULTY (NEW GAME's destination, EASY/NORMAL/HARD/BACK) is also inside
//! GP_TITLE, then NEW GAME loads nothing external and that reading is wrong.
//!
//! cargo run -p sylpheed-formats --example gp_title_buttons
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else { continue };
let Some(b) = ui_layout::parse_build(&by) else {
continue;
};
let mut btns: Vec<String> = b
.records
.keys()
.filter(|n| n.starts_with("ptbtn"))
.cloned()
.collect();
btns.sort();
if btns.is_empty() {
continue;
}
println!("entry {i:2} {:2} button records {:?}", btns.len(), btns);
}
}

View File

@@ -1,24 +0,0 @@
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").unwrap());
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).unwrap();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(by) = ar.read(e) else {
println!("{i:2} <unreadable>");
continue;
};
let names: Vec<String> = ui_layout::parse_build(&by)
.map(|b| b.sprites.keys().take(2).cloned().collect())
.unwrap_or_default();
let rec: Vec<String> = ui_layout::parse_build(&by)
.map(|b| b.records.keys().take(2).cloned().collect())
.unwrap_or_default();
println!(
"{i:2} {} B sprites {:?} records {:?}",
by.len(),
names,
rec
);
}
}

View File

@@ -1,57 +0,0 @@
//! Is `GP_TITLE.pak` really "8 screens shipped twice, EN/JP"?
//!
//! The Q2 headline says each screen appears twice. The entry dump raised a
//! doubt: entry 11 shows `palogo_gamearts` and entry 14 shows `palogo_seta`,
//! which are different studios, not a language pair. If the two halves of a
//! "pair" declare different sprites, "shipped twice" is the wrong description of
//! at least that pair.
//!
//! CONTROL: a pair known to be a real EN/JP pair must come out as matching. 2/3
//! (the PRESS Ⓐ plate) is byte-identical in size and is the control.
//!
//! cargo run -p sylpheed-formats --example gp_title_pair_check
use std::collections::BTreeSet;
use std::path::PathBuf;
use sylpheed_formats::{pak::PakArchive, ui_layout};
fn sprites(ar: &PakArchive, i: usize) -> BTreeSet<String> {
let Ok(by) = ar.read(&ar.entries()[i]) else {
return BTreeSet::new();
};
ui_layout::parse_build(&by)
.map(|b| b.sprites.keys().cloned().collect())
.unwrap_or_default()
}
fn main() {
let root = PathBuf::from(std::env::var("SYLPHEED_DISC").expect("SYLPHEED_DISC"));
let ar = PakArchive::open(root.join("dat/GP_TITLE.pak")).expect("GP_TITLE.pak");
let pairs = [
(0, 1, "loading plain"),
(2, 3, "PRESS (A) plate [CONTROL]"),
(4, 7, "title art"),
(5, 8, "main menu"),
(6, 9, "EXTRAS"),
(10, 13, "publisher splash"),
(11, 14, "developer splash"),
(12, 15, "loading dressed"),
];
for (a, b, what) in pairs {
let (sa, sb) = (sprites(&ar, a), sprites(&ar, b));
let only_a: Vec<_> = sa.difference(&sb).cloned().collect();
let only_b: Vec<_> = sb.difference(&sa).cloned().collect();
let shared = sa.intersection(&sb).count();
let verdict = if only_a.is_empty() && only_b.is_empty() {
"IDENTICAL SET"
} else {
"DIFFERS"
};
println!("\n{a:2}/{b:<2} {what:26} {shared:3} shared {verdict}");
if !only_a.is_empty() {
println!(" only in {a}: {only_a:?}");
}
if !only_b.is_empty() {
println!(" only in {b}: {only_b:?}");
}
}
}

View File

@@ -1,7 +1,2 @@
fn main() { fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
let a: Vec<String> = std::env::args().collect(); for l in sylpheed_formats::mesh::debug_grouped_report(&b,&a[2],a[3].parse().unwrap()){println!("{l}")}}
let b = std::fs::read(&a[1]).unwrap();
for l in sylpheed_formats::mesh::debug_grouped_report(&b, &a[2], a[3].parse().unwrap()) {
println!("{l}")
}
}

Some files were not shown because too many files have changed in this diff Show More