Merge pull request 'Clear all 73 clippy lints, and make the Clippy step real' (#14) from fix/clippy-lints into main
Some checks failed
CI / Native — linux (push) Successful in 35m13s
CI / WASM — Web (push) Failing after 8m3s
CI / Formatting (push) Failing after 44s

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-09-07 19:58:07 +00:00
22 changed files with 572 additions and 77 deletions

View File

@@ -38,7 +38,6 @@ use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use tracing::info;
use sylpheed_formats::vfs::{identify_format, GameAssets};
use sylpheed_formats::{IdxdObject, PakArchive};
@@ -572,6 +571,11 @@ fn print_geometry(b: &sylpheed_formats::ui_layout::UiBuild, bytes: &[u8]) {
}
}
// 8 parameters against a threshold of 7 — a plain function, unlike the Bevy
// systems in the viewer, so this one is real if mild. Left as-is because the
// arguments are the CLI flags this subcommand takes; grouping them into a
// struct is a change to the command surface, not a lint fix.
#[allow(clippy::too_many_arguments)]
fn cmd_screen_render(
pak: &Path,
output: &Path,
@@ -892,7 +896,7 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
println!();
println!("{}", "Format Summary:".bold());
let mut summary: Vec<_> = counts.into_iter().collect();
summary.sort_by(|a, b| b.1.cmp(&a.1));
summary.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
for (fmt, count) in summary {
println!(
" {:>6} .{}",
@@ -1034,7 +1038,7 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
let nv = sub.positions.len();
let mut referenced = vec![false; nv];
let (mut degen, mut oob, mut imax) = (0usize, 0usize, 0u32);
for tri in sub.indices.chunks_exact(3) {
for tri in sub.indices.as_chunks::<3>().0 {
let (a, b, c) = (tri[0], tri[1], tri[2]);
imax = imax.max(a).max(b).max(c);
if a == b || b == c || a == c {
@@ -1056,7 +1060,9 @@ fn cmd_mesh_info(file: &Path) -> Result<()> {
};
let mut maxedges: Vec<f32> = sub
.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.map(|t| edge(t[0], t[1]).max(edge(t[1], t[2])).max(edge(t[0], t[2])))
.collect();
maxedges.sort_by(|a, b| a.partial_cmp(b).unwrap());
@@ -1220,7 +1226,9 @@ fn cmd_mesh_render(
let med = {
let mut e: Vec<f32> = sub
.indices
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.filter(|t| (t[0] as usize) < n && (t[1] as usize) < n && (t[2] as usize) < n)
.map(|t| {
let d = |a: u32, b: u32| {
@@ -1254,7 +1262,7 @@ fn cmd_mesh_render(
(p[2] - center[2]) * scale * mirror[2] + cell[2],
]
};
for tri in sub.indices.chunks_exact(3) {
for tri in sub.indices.as_chunks::<3>().0 {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a < n && b < n && c < n {
if span_only || span_hide {
@@ -1440,7 +1448,9 @@ fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u
// [A,R,G,B] byte order (verified against the retail Acheron backdrop).
// Emit RGBA. X8 has no meaningful alpha.
let opaque = matches!(tex.format, F::X8R8G8B8);
for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
let src = tex.data.as_chunks::<4>().0;
let dst = rgba.as_chunks_mut::<4>().0;
for (px, out) in src.iter().zip(dst) {
out[0] = px[1]; // R
out[1] = px[2]; // G
out[2] = px[3]; // B
@@ -1690,12 +1700,12 @@ fn cmd_pak_textures(pak: &Path, output: &Path, verbose: bool) -> Result<()> {
let mut idx = 0usize;
while let Some(pos) = payload[off..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
.position(|w| w == t8ad::T8AD_MAGIC)
{
let start = off + pos;
let next = payload[start + 4..]
.windows(4)
.position(|w| w == &t8ad::T8AD_MAGIC)
.position(|w| w == t8ad::T8AD_MAGIC)
.map(|p| start + 4 + p)
.unwrap_or(payload.len());
emit_t8ad(

View File

@@ -109,6 +109,8 @@ pub struct BgmSpec {
pub loop_start_s: Option<f64>,
#[serde(default)]
pub loop_end_s: Option<f64>,
// Deserialised to model the sidecar schema, not read in Rust.
#[allow(dead_code)]
#[serde(default)]
pub loop_end_why: Option<serde_json::Value>,
#[serde(default)]

View File

@@ -158,6 +158,9 @@ fn load_names(authored: &Path) -> Result<NameMap> {
#[derive(serde::Deserialize)]
struct File {
archives: NameMap,
// Deserialised to model the on-disc schema, not read in Rust.
// Removing it would silently change what this struct accepts.
#[allow(dead_code)]
#[serde(default)]
also_export: AlsoExport,
}
@@ -275,7 +278,7 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
// `video/` that this run did not claim, so a movie that stops being exported
// still stops existing.
if out.exists() {
for entry in std::fs::read_dir(&out).context("clear the output tree")? {
for entry in std::fs::read_dir(out).context("clear the output tree")? {
let entry = entry?;
if entry.file_name() == "video" {
continue;
@@ -288,7 +291,7 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
.with_context(|| format!("clear {}", entry.path().display()))?;
}
}
std::fs::create_dir_all(&out)?;
std::fs::create_dir_all(out)?;
let archive = "dat/GP_TITLE.pak";
let pak = disc.join(archive);
@@ -315,7 +318,7 @@ fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
None => (format!("build_{entry:02}"), "index", None),
};
let ex = screen::export_build(
&out,
out,
archive,
*entry,
build_idx,

View File

@@ -226,9 +226,10 @@ fn parse_riff_wave(bytes: &[u8]) -> Option<AudioInfo> {
match codec {
AudioCodec::Pcm | AudioCodec::PcmFloat => {
if let (Some(d), true) = (data_bytes, channels > 0 && bits > 0) {
// `bits` under 8 makes the frame size zero, so this stays
// fallible: `checked_div` states that once, where it happens.
let frame = channels as u64 * (bits as u64 / 8);
if frame > 0 {
let spc = d / frame;
if let Some(spc) = d.checked_div(frame) {
info.samples_per_channel = Some(spc);
if rate > 0 {
info.duration_secs = Some(spc as f32 / rate as f32);
@@ -275,11 +276,15 @@ impl GameAudio {
let samples: Vec<f32> = match (info.codec, bits) {
(AudioCodec::Pcm, 8) => data.iter().map(|&b| (b as f32 - 128.0) / 128.0).collect(),
(AudioCodec::Pcm, 16) => data
.chunks_exact(2)
.as_chunks::<2>()
.0
.iter()
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect(),
(AudioCodec::Pcm, 24) => data
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.map(|c| {
let v = ((c[2] as i32) << 16) | ((c[1] as i32) << 8) | c[0] as i32;
let v = (v << 8) >> 8; // sign-extend 24→32
@@ -287,7 +292,9 @@ impl GameAudio {
})
.collect(),
(AudioCodec::PcmFloat, 32) => data
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect(),
_ => return Err(AudioError::UnsupportedPcm { tag: 0, bits }),

View File

@@ -1131,7 +1131,9 @@ pub fn load_squadrons(pak: &PakArchive) -> Vec<Squadron> {
let Some(f) = records.record(id) else { continue };
let slots: Vec<&str> = f.positional.iter().map(|(_, v)| v.as_str()).collect();
let members = slots
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|m| SquadronMember {
unit: m[0].to_string(),
message_set: text(Some(m[1])),

View File

@@ -71,7 +71,7 @@ pub fn name_hash(name: &str) -> u32 {
// through untouched, including any high-bit bytes, which are then sign-extended).
let mut bytes = name.as_bytes().to_vec();
for byte in &mut bytes {
if (b'A'..=b'Z').contains(byte) {
if byte.is_ascii_uppercase() {
*byte += 0x20;
}
}

View File

@@ -119,7 +119,7 @@ pub fn parse(bytes: &[u8]) -> Option<Subtitle> {
fn utf16be_tokens(bytes: &[u8]) -> Vec<String> {
let mut tokens = Vec::new();
let mut cur = String::new();
for pair in bytes.chunks_exact(2) {
for pair in bytes.as_chunks::<2>().0 {
let u = u16::from_be_bytes([pair[0], pair[1]]);
match char::from_u32(u as u32) {
Some(c) if !c.is_control() => cur.push(c),

View File

@@ -700,7 +700,7 @@ impl Xbg7Model {
let (vc, ic) = r.markers[0];
let sig = (r.decl.stride, vc, ic);
let floor = last_by_sig.get(&sig).map_or(0, |o| o + 1);
if m.meshes[0].vbuf_offset.map_or(false, |o| o < floor) {
if m.meshes[0].vbuf_offset.is_some_and(|o| o < floor) {
if let Some(alt) = anchor_pool_mesh(
bytes,
starts,
@@ -1339,6 +1339,13 @@ fn vertex_run_starts(bytes: &[u8], data_base: usize, stride: usize) -> Vec<usize
/// the `index_count` indices ending just before it are all `< vtx_count`,
/// reference (nearly) all vertices, and produce non-degenerate triangles with a
/// real spatial extent — a signature strong enough to pin the block.
// 8 parameters against clippy's threshold of 7. Not refactored here on
// purpose: the fix is a shared params struct across this and the two
// `validate_block*` functions below, which is a change to the decoder's
// mesh-anchoring signatures and belongs to whoever owns that path — not to
// a CI-lint pass. Recorded rather than silenced: this allow covers one lint
// at one site, and any new violation elsewhere still fails the build.
#[allow(clippy::too_many_arguments)]
fn anchor_pool_mesh(
bytes: &[u8],
starts: &[usize],
@@ -1406,7 +1413,7 @@ fn anchor_pool_mesh(
}
let (degen, wind) = index_run_quality(bytes, ib, vb, index_count, decl);
let cand = (degen, -wind, pad);
if best.map_or(true, |b| cand < b) {
if best.is_none_or(|b| cand < b) {
best = Some(cand);
}
}
@@ -1452,6 +1459,11 @@ fn anchor_pool_mesh(
/// for both the adjacency anchor ([`anchor_pool_mesh`]) and the grouped-pool
/// anchor ([`anchor_grouped_meshes`]); the two differ only in how they *place*
/// `ib`/`vb`, not in how they validate a placement.
// 8/7, as above. This and `validate_block_report` take the *same* eight
// parameters — one delegates straight to the other — which is exactly why a
// params struct is the right fix and why it should be done once, deliberately,
// rather than piecemeal here.
#[allow(clippy::too_many_arguments)]
fn validate_block(
bytes: &[u8],
ib: usize,
@@ -1478,6 +1490,8 @@ fn validate_block(
/// [`validate_block`], but naming the gate that rejected a block. A runtime
/// capture can prove a block is real; when the decoder still refuses it, this
/// says which test is wrong rather than leaving a threshold to be guessed at.
// 8/7 — the signature `validate_block` delegates into. See above.
#[allow(clippy::too_many_arguments)]
fn validate_block_report(
bytes: &[u8],
ib: usize,
@@ -1679,7 +1693,7 @@ fn anchor_grouped_meshes(
if n == 0 || decls.len() < n {
return Vec::new();
}
let decl = &decls[0];
let _decl = &decls[0];
// Relative index-buffer offsets (ib0 = 0), 4-byte aligned between buffers,
// and cumulative vertex offsets (vb0 = 0) — both derived from the marker list.
@@ -1723,7 +1737,7 @@ fn anchor_grouped_meshes(
let (vc, ic) = markers[i];
let ib = ib0 + rel_ib[i];
if ib + ic * 2 > bytes.len()
|| vc.checked_mul(decls[i].stride).map_or(true, |b| vb + b > bytes.len())
|| vc.checked_mul(decls[i].stride).is_none_or(|b| vb + b > bytes.len())
{
break;
}
@@ -1784,7 +1798,7 @@ fn anchor_grouped_meshes(
}
let (degen, wind) = index_run_quality(bytes, ib_k, vb_k, ick, &decls[kmax]);
let cand = (degen, -wind, pad);
if best.map_or(true, |b| cand < b) {
if best.is_none_or(|b| cand < b) {
best = Some(cand);
}
if pad_first_match() {
@@ -1797,7 +1811,7 @@ fn anchor_grouped_meshes(
if meshes.len() == n {
return meshes;
}
if partial.as_ref().map_or(true, |p| meshes.len() > p.len()) {
if partial.as_ref().is_none_or(|p| meshes.len() > p.len()) {
partial = Some(meshes);
}
}
@@ -1929,7 +1943,7 @@ fn find_index_marker(desc: &[u8]) -> Option<(usize, usize)> {
while rel + 40 <= desc.len() {
let a = be32(desc, rel);
let c = be32(desc, rel + 4);
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 {
if c >= 3 && c.is_multiple_of(3) && c < 400_000 && a == c * 2 {
return Some((rel, c as usize));
}
rel += 4;
@@ -1951,7 +1965,7 @@ fn all_index_markers(desc: &[u8]) -> Vec<(usize, usize)> {
while rel + 8 <= desc.len() {
let a = be32(desc, rel);
let c = be32(desc, rel + 4);
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 && rel >= 32 {
if c >= 3 && c.is_multiple_of(3) && c < 400_000 && a == c * 2 && rel >= 32 {
let vc = be32(desc, rel - 32) as usize;
if (3..=200_000).contains(&vc) {
out.push((vc, c as usize));
@@ -1979,7 +1993,7 @@ fn all_vertex_decls(desc: &[u8]) -> Vec<VertexDecl> {
while rel + 8 <= desc.len() {
let a = be32(desc, rel);
let c = be32(desc, rel + 4);
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 && rel >= 32 {
if c >= 3 && c.is_multiple_of(3) && c < 400_000 && a == c * 2 && rel >= 32 {
let vc = be32(desc, rel - 32) as usize;
if (3..=200_000).contains(&vc) {
match parse_decl_at(desc, rel + 8) {
@@ -2086,9 +2100,8 @@ fn submesh_records(desc: &[u8]) -> Vec<(usize, usize)> {
let t = be32(desc, rel + 12);
if (3..=65535).contains(&a)
&& z == 0
&& c >= 3
&& c <= 200_000
&& c % 3 == 0
&& (3..=200_000).contains(&c)
&& c.is_multiple_of(3)
&& (1..=64).contains(&t)
{
out.push((a as usize, c as usize));
@@ -2151,7 +2164,7 @@ fn topology_report(
let mut agree = 0usize;
let mut counted = 0usize;
let mut edges: HashMap<(u32, u32), u32> = HashMap::new();
for t in indices.chunks_exact(3) {
for t in indices.as_chunks::<3>().0 {
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
if a == b || b == c || a == c {
degen += 1;
@@ -2330,7 +2343,7 @@ pub fn submesh_albedos(bytes: &[u8], resource_name: &str) -> Vec<(usize, usize,
if (3..=65535).contains(&vtx)
&& z == 0
&& idx >= 3
&& idx % 3 == 0
&& idx.is_multiple_of(3)
&& idx < 400_000
&& tail > 0
&& tail < 0x10_0000
@@ -2423,7 +2436,7 @@ pub fn material_groups(bytes: &[u8], resource_name: &str, target: usize) -> Vec<
if (1..=70000).contains(&vtx)
&& tail == 4
&& cnt >= 3
&& cnt % 3 == 0
&& cnt.is_multiple_of(3)
&& cnt < 400_000
&& (off as usize) < 4_000_000
{

View File

@@ -2,7 +2,7 @@
//! table (statically reversed). Beyond the movie→voice binding, it defines the
//! whole cutscene *structure*: which video (+ subtitle, + on-screen text overlay,
//! + voice) plays at each mission phase, and of what kind (story intro, in-mission
//! phase cutscene, resupply, …).
//! phase cutscene, resupply, …).
//!
//! ## On-disc structure (`dat/tables.pak`, entry hash `0x5b983a08`)
//!
@@ -339,12 +339,10 @@ fn ascii_runs(bytes: &[u8], min: usize) -> Vec<String> {
for &b in bytes {
if (0x20..=0x7e).contains(&b) {
cur.push(b as char);
} else if cur.len() >= min {
out.push(std::mem::take(&mut cur));
} else {
if cur.len() >= min {
out.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
cur.clear();
}
}
if cur.len() >= min {

View File

@@ -96,7 +96,7 @@ pub fn find_descriptor_before(buf: &[u8], before: usize) -> Option<usize> {
let mut o = (before - 1).min(cap) & !3;
loop {
let id = be(o);
if id >= 1 && id < ID_MAX && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
if (1..ID_MAX).contains(&id) && be(o + 4) == DESC_MARK && be(o + DESC_REPEAT) == id {
return Some(o);
}
if o < 4 {

View File

@@ -287,13 +287,13 @@ pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
let mut pos: Vec<[f32; 3]> = Vec::new();
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
let mut flush = |base: u32,
size: u32,
stride: u32,
pos: &mut Vec<[f32; 3]>,
consts: &[(usize, [f64; 4])],
seen: &mut std::collections::HashSet<u32>,
out: &mut Vec<CapturedDraw>| {
let flush = |base: u32,
size: u32,
stride: u32,
pos: &mut Vec<[f32; 3]>,
consts: &[(usize, [f64; 4])],
seen: &mut std::collections::HashSet<u32>,
out: &mut Vec<CapturedDraw>| {
let pos = std::mem::take(pos);
if base == 0 || stride == 0 || !seen.insert(base) {
return;
@@ -426,7 +426,7 @@ pub fn correlate(
let Some((score, mirrored)) = pos_validate(d, &key.ref_pos) else {
continue; // positions disagree — not this part
};
if best.map_or(true, |(_, s, _)| score > s) {
if best.is_none_or(|(_, s, _)| score > s) {
best = Some((d, score, mirrored));
}
}

View File

@@ -517,7 +517,7 @@ pub fn to_xma_riff_best(slb: &[u8]) -> Option<Vec<u8>> {
while let Some(di) = find(slb, b"data", i) {
let declared = le32(slb, di + 4).unwrap_or(0) as usize;
let usable = declared.min(slb.len().saturating_sub(di + 8));
if best.map_or(true, |(_, b)| usable > b) {
if best.is_none_or(|(_, b)| usable > b) {
best = Some((di, usable));
}
i = di + 4;

View File

@@ -587,19 +587,19 @@ pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
match endianness {
1 => {
// k8in16 — swap the two bytes of each 16-bit half.
for c in data.chunks_exact_mut(2) {
for c in data.as_chunks_mut::<2>().0 {
c.swap(0, 1);
}
}
2 => {
// k8in32 — reverse each 32-bit word.
for c in data.chunks_exact_mut(4) {
for c in data.as_chunks_mut::<4>().0 {
c.reverse();
}
}
3 => {
// k16in32 — swap the two 16-bit halves of each 32-bit word.
for c in data.chunks_exact_mut(4) {
for c in data.as_chunks_mut::<4>().0 {
c.swap(0, 2);
c.swap(1, 3);
}
@@ -626,8 +626,8 @@ fn decode_surface(
} else {
// Linear layout — copy only the mip-0 slice.
let block_size = format.block_size() as u32;
let bw = ((width + block_size - 1) / block_size).max(1);
let bh = ((height + block_size - 1) / block_size).max(1);
let bw = width.div_ceil(block_size).max(1);
let bh = height.div_ceil(block_size).max(1);
let needed = bw as usize * bh as usize * format.bytes_per_block();
if raw_data.len() < needed {
return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
@@ -651,7 +651,7 @@ fn decode_surface(
match format {
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
for block in linear_data.chunks_exact_mut(16) {
for block in linear_data.as_chunks_mut::<16>().0 {
swap_bc_block_dwords(&mut block[8..16]);
}
}
@@ -666,8 +666,8 @@ fn decode_surface(
/// rounded up to the 4 KiB subresource alignment (`kTextureSubresourceAlignmentBytes`).
fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usize {
let bs = format.block_size() as u32;
let bw = ((width + bs - 1) / bs).max(1);
let bh = ((height + bs - 1) / bs).max(1);
let bw = width.div_ceil(bs).max(1);
let bh = height.div_ceil(bs).max(1);
let pitch_aligned = align_up(bw, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let height_aligned = align_up(bh, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let surface = pitch_aligned as usize * height_aligned as usize * format.bytes_per_block();
@@ -682,7 +682,7 @@ fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usiz
/// after the byte-level endian swap. Any trailing bytes that don't fill a full
/// 8-byte group are left untouched.
pub fn swap_bc_block_dwords(data: &mut [u8]) {
for unit in data.chunks_exact_mut(8) {
for unit in data.as_chunks_mut::<8>().0 {
// [d0 d1 d2 d3 | d4 d5 d6 d7] → [d4 d5 d6 d7 | d0 d1 d2 d3]
let (lo, hi) = unit.split_at_mut(4);
lo.swap_with_slice(hi);
@@ -742,8 +742,8 @@ pub fn detile(
let bpb_log2 = (bpb as u32).trailing_zeros();
// Visible dimensions in blocks, and the padded storage pitch/height.
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
let blocks_wide = width.div_ceil(block_size).max(1);
let blocks_tall = height.div_ceil(block_size).max(1);
let pitch_aligned = align_up(blocks_wide, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let height_aligned = align_up(blocks_tall, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);

View File

@@ -910,7 +910,7 @@ pub fn compose(
// A dim backdrop stands in for the PRMD dim-quad + the live 3D scene behind
// an in-mission screen.
let mut canvas = vec![0u8; (w as usize) * (h as usize) * 4];
for px in canvas.chunks_exact_mut(4) {
for px in canvas.as_chunks_mut::<4>().0 {
px.copy_from_slice(&opts.backdrop);
}
let mut drawn = Vec::new();

View File

@@ -237,7 +237,9 @@ pub fn decode_text(bytes: &[u8]) -> (String, &'static str) {
fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
let units: Vec<u16> = bytes
.chunks_exact(2)
.as_chunks::<2>()
.0
.iter()
.map(|c| {
if big_endian {
u16::from_be_bytes([c[0], c[1]])

View File

@@ -53,7 +53,7 @@ impl<F: Read + Seek + Send + Sync + 'static> XisoReader<F> {
info!(
"Opened XISO: root directory table at sector {}",
{ let s = volume.root_table.region.sector; s }
{ volume.root_table.region.sector }
);
Ok(Self { volume, file: wrapper })
}

View File

@@ -101,7 +101,7 @@ pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result<Image, Xpr2LoadErr
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
let opaque = matches!(tex.format, X360TextureFormat::X8R8G8B8);
let mut out = tex.data;
for px in out.chunks_exact_mut(4) {
for px in out.as_chunks_mut::<4>().0 {
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
px[0] = r;
px[1] = g;

View File

@@ -2035,6 +2035,10 @@ fn decode_audio_wav(video: &Path, wav: &Path) -> Result<(), String> {
// ── Video decode + audio (main thread) ────────────────────────────────────────
/// `(pts, rgba_bytes)` as the reader thread forwards them from `ffmpeg`.
#[cfg(not(target_arch = "wasm32"))]
type FrameRx = mpsc::Receiver<(f32, Vec<u8>)>;
/// Spawn an `ffmpeg` process decoding to raw RGBA on stdout, plus a reader
/// thread that chunks it into frames and forwards `(pts, bytes)` over a bounded
/// channel. Optional `-ss start` seeks the input; output pts re-base to 0, so we
@@ -2046,7 +2050,7 @@ fn spawn_video_decoder(
h: u32,
fps: f32,
start: f32,
) -> Result<(Child, mpsc::Receiver<(f32, Vec<u8>)>), String> {
) -> Result<(Child, FrameRx), String> {
let mut cmd = Command::new("ffmpeg");
cmd.arg("-v").arg("error");
if start > 0.0 {
@@ -2177,6 +2181,12 @@ fn scrub_worker(
/// Polls the mpsc channel, updating `IsoState`, `FileBrowserState`, and
/// `PendingFileBytes` as messages arrive.
// Bevy system: every parameter is a `Res`/`ResMut`/`EventWriter` the
// scheduler injects. The count is the framework's dependency list, not a
// signature anyone calls by hand, and it cannot be reduced without
// bundling into a `SystemParam` struct. clippy's general heuristic does
// not know about the idiom.
#[allow(clippy::too_many_arguments)]
#[cfg(not(target_arch = "wasm32"))]
fn poll_loader_channel(
channels: Res<IsoChannels>,
@@ -2557,7 +2567,7 @@ fn pick_albedo_index(model_name: &str, tex_names: &[String]) -> Option<usize> {
#[cfg(not(target_arch = "wasm32"))]
fn compute_smooth_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
let mut acc = vec![Vec3::ZERO; positions.len()];
for tri in indices.chunks_exact(3) {
for tri in indices.as_chunks::<3>().0 {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
if a >= positions.len() || b >= positions.len() || c >= positions.len() {
continue;
@@ -3106,7 +3116,7 @@ fn prepare_models_impl(
// Append the slice's triangles, re-emitting vertices per index
// (no dedup) so each material buffer stays self-contained. A
// reflected instance swaps two corners to keep front faces out.
for tri in sub.indices[(*off).min(end)..end].chunks_exact(3) {
for tri in sub.indices[(*off).min(end)..end].as_chunks::<3>().0 {
let corners = if reflect {
[tri[0], tri[2], tri[1]]
} else {
@@ -3405,6 +3415,12 @@ fn apply_prepared_xpr(
/// Consumes a staged pack, freeing the previous texture/text previews and
/// populating `PakView` for the master-detail browser.
// Bevy system: every parameter is a `Res`/`ResMut`/`EventWriter` the
// scheduler injects. The count is the framework's dependency list, not a
// signature anyone calls by hand, and it cannot be reduced without
// bundling into a `SystemParam` struct. clippy's general heuristic does
// not know about the idiom.
#[allow(clippy::too_many_arguments)]
#[cfg(not(target_arch = "wasm32"))]
fn apply_pak(
mut pending: ResMut<PendingPak>,
@@ -4076,7 +4092,7 @@ fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
Some(CharRow { name, faction: c.faction.unwrap_or_default(), faces: c.faces.len() })
})
.collect();
characters.sort_by(|a, b| (a.faction.clone(), a.name.clone()).cmp(&(b.faction.clone(), b.name.clone())));
characters.sort_by_key(|a| (a.faction.clone(), a.name.clone()));
// Combat rosters, keyed by stage where the table self-identifies.
let rosters = gd::load_unit_rosters(&main);
@@ -4742,7 +4758,7 @@ fn build_ship_model(
*nrm = rot(&p.m, nrm);
}
if det < 0.0 {
for tri in sub.indices.chunks_exact_mut(3) {
for tri in sub.indices.as_chunks_mut::<3>().0 {
tri.swap(1, 2);
}
}
@@ -4911,7 +4927,7 @@ fn handle_cutscene_cues_request(
// The naming convention is the obvious route and it is wrong often
// enough to matter -- resolving the region is the only reading that
// yields the right audio.
let voice = (|| {
let voice = {
use sylpheed_formats::{hash::name_hash, media, PakArchive};
let vlang = match lang.pak_code() {
"jpn" => sylpheed_formats::slb::VoiceLang::Japanese,
@@ -4934,7 +4950,7 @@ fn handle_cutscene_cues_request(
named_range,
region,
})
})();
};
let _ = sender.send(IsoLoaderMsg::CutsceneCuesLoaded {
generation,

View File

@@ -50,7 +50,7 @@ pub fn run() {
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Project Sylpheed: Arc of Deception — Asset Viewer".into(),
resolution: (1280.0, 720.0).into(),
resolution: (1280.0_f32, 720.0_f32).into(),
..default()
}),
..default()

View File

@@ -157,6 +157,12 @@ struct UiEvents<'w> {
cutscenes: EventWriter<'w, RequestCutscenes>,
}
// Bevy system: every parameter is a `Res`/`ResMut`/`EventWriter` the
// scheduler injects. The count is the framework's dependency list, not a
// signature anyone calls by hand, and it cannot be reduced without
// bundling into a `SystemParam` struct. clippy's general heuristic does
// not know about the idiom.
#[allow(clippy::too_many_arguments)]
fn draw_viewer_ui(
mut contexts: EguiContexts,
mut viewer: ResMut<ViewerState>,
@@ -656,7 +662,7 @@ fn draw_viewer_ui(
ui.strong("Notes");
ui.end_row();
let mut row = |ui: &mut egui::Ui, fmt: &str, c, status: &str, notes: &str| {
let row = |ui: &mut egui::Ui, fmt: &str, c, status: &str, notes: &str| {
ui.label(fmt);
ui.colored_label(c, status);
ui.label(notes);
@@ -1236,7 +1242,7 @@ fn draw_video_player(
// Subtitle + voice controls (right-aligned): language, CC, and Voice.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let before = subs.lang;
egui::ComboBox::from_id_source("subtitle_lang")
egui::ComboBox::from_id_salt("subtitle_lang")
.selected_text(subs.lang.label())
.show_ui(ui, |ui| {
for lang in SubLang::ALL {

View File

@@ -0,0 +1,397 @@
# Handoff — 2026-09-06
**For a fresh session on a different machine.** Written to be read cold: it
assumes you know nothing about what happened, and it says what is *established*
versus what is *someone's claim*.
---
## 1. What this project is
Two things, and they are easy to confuse:
* **The long game** — *Project Sylpheed: Arc of Deception — Reborn*, a clean-room
native port of an Xbox 360 game in Rust + Bevy. Values and behaviour come from
the original by **observation and static RE only** — never copied decompiled
code. The oracle is **the real game running in Xenia Canary**, never any
renderer of ours.
* **The work of 2026-09-04/06, which is what this document is about** — moving
the project's *working surface* onto a self-hosted Gitea, and getting CI to
produce an answer for the first time.
If you only read one other file, read [`PROTOCOL.md`](PROTOCOL.md).
## 2. The actors — four, and only two are constrained
| | what it is | identity | constrained by the gate? |
|---|---|---|---|
| **the human** | directs everything; the only approver and merger | `fabi` | n/a — is the gate |
| **the Pi agent** | supervisor, runs on the Pi beside Gitea | `sylph-pi <pi@sylpheed.local>`, **no Gitea account** | ❌ has `gitea admin`; can mint tokens, edit rules |
| **you** (this session) | runs on the x86_64 desktop, holds the push credential | commits as the human ⚠️ | ❌ same carve-out, different mechanism |
| **the two loop agents** | Decoder (disc→meaning) and Port (disc→playable) | `sylph-decoder`, `sylph-port`, Write not Admin | ✅ |
⚠️ **The looping agents are stopped and must stay stopped** until their images
carry the Gitea MCP and their issues are ready. Starting them early gives them a
brief telling them to read notifications and open issues with no tool that can.
📌 The two *supervising* agents are the ones the gate does not bind. That is
written into `GITEA-SETUP.md` Phase 2 deliberately. Neither has a distinct Gitea
identity; both operate through the human's credential or an unlinked git author.
**That is a known, unresolved wart**, not an oversight.
## 3. The machines
| | | |
|---|---|---|
| **desktop** `fabi-Hyrican-PC` | x86_64, 12 core, 15 GB | agent containers, the repo clone, **the only push credential** |
| **the Pi** `raspberrypi.fritz.box` | aarch64, on the LAN | **runs Gitea** (published through a VPS), the CI runner, and the supervising agent |
| **Gitea** `git.mc02.dev` | 1.25.5 | resolves to a hosted address — that says nothing about the origin, which is the Pi |
**File transfer between them is by hand.** The Pi agent has no push credential,
so its work arrives as `git bundle` over `scp`, which the human runs. This is the
weakest link in the setup: four round trips on 2026-09-04, each needing a
password twice, and once a guessed filename that was wrong. A `write:repository`
token on the Pi scoped to `pi/*` branches would remove it — **a deliberate
decision, deliberately not taken yet.**
⚠️ **`CARGO_BUILD_JOBS=4`** and limited `-j`. A full-parallel build has
OOM-crashed the desktop. One emulator process at a time; Canary runs muted.
## 4. Where things stand
### Merged to `main`
Nothing since `59649824`. **`main` is protected** — `enable_push=false`, 1
approval required, merge and approvals both whitelisted to `fabi` only. Verified
behaviourally: a real push was refused with `pre-receive hook declined`, as the
repository owner.
### Open pull requests
| | head | what | why not merged |
|---|---|---|---|
| **#10** | `agents/gitea-mcp` @ `a3d99ada` | the Gitea surface: MCP wiring, `gitea-protect`, the runbook, two PROTOCOL rules | waiting on the human. Merge-on-merits: docs and tooling, no `.rs` |
| **#14** | `fix/clippy-lints` @ `d8807c4f` | 73 clippy lints → 0 across four crates, `Closes #13` | same |
🔴 **Both need the admin override to merge.** `fabi` authored them and is the
only whitelisted approver, and Gitea bars self-approval — so they can never reach
one approval. `block_admin_merge_override` is `false` *deliberately* so that door
stays open. Do not tick it.
### Open issues
Nine work items, all `state/approved`, awaiting the loop agents:
```
#1 F1 measure (decoder) ←blocks— #2 F1 implement (port)
#3 F2 disc gain (decoder) ←blocks— #4 F2 playback gains (port)
#5 F3 title cue (decoder)
#6 OPTIONS re-propose (port)
#8 F5/F6 findings (decoder) ←blocks— #7 F5/F6 port work (port)
#9 f6-out-of-sample residue (decoder)
```
Four infrastructure issues:
* **#11** `state/proposed` — WASM. **Work exists past its shape; see §6.**
* **#12** `state/proposed` — rustfmt: 774 hunks, deliberately deferred.
* **#13** `state/approved` — clippy; `#14` closes it.
* **#15** `state/proposed` — the lint gate floats `@stable`.
### Unmerged branches
```
agents/gitea-mcp 11 → PR #10
fix/clippy-lints 16 → PR #14
auto/frame-blend-draw-path 495 the Decoder's corpus — returns via #8
auto/port-p6-audio 366 the Port's work — returns via #7
```
The last two are the reason `#7` depends on `#8`: `port/scripts/boot.gd` cites
`docs/re/` pages that exist on **neither** its own branch nor `main`.
`tools/port/check-citations --for-merge` counts **19** such citations.
## 5. CI — it produced its first answer on 2026-09-05
Before that: **23 runs cancelled, 2 waiting, zero successes.** The workflow
described GitHub's hosted fleet (`windows-latest`, `macos-latest`, and a
`--target x86_64` cross-compile) on a one-runner aarch64 instance, so the run
never reached a terminal state — the checks were *unfinished*, not red.
Now, three jobs, all terminal:
| job | state | |
|---|---|---|
| **Native — linux** | **green**, three runs running | `check` `build` `test` `clippy` all pass on aarch64; **207 passed, 0 failed, 14 ignored, 30 suites** |
| WASM — Web | red | **#11** |
| Formatting | red | **#12** — 774 hunks, identical on `main` |
The workspace being portable to ARM was unknown before this and is now
established. The disk exhaustion that broke the test link is retired: 46 GB
reclaimed, `/` at 55%.
⚠️ **`/var/lib/docker` is still on the Pi's 117 GB SD card while a 916 GB SSD sits
at 16%.** This will recur. The fix is `data-root` in `daemon.json` plus a Docker
restart, and it wants a moment when every container going down is fine.
## 6. 🔴 What is in flight and NOT pushed
**The WASM work.** The Pi agent has `ba6c5da`, bundle at
`/tmp/sylph-wasm-compile.bundle` **on the Pi**, base `d8807c4`. It makes
`Check WASM compile` exit 0. #11 turned out to be **three stacked blockers**,
each invisible until the previous was gone:
1. `getrandom` needs `--cfg getrandom_backend="wasm_js"` **and** the feature —
its own error says either alone is insufficient;
2. `sylpheed-formats` declared `tokio` as a **normal** dependency it never used,
dragging `tokio/full``net``mio`, which does not build for wasm32;
3. `bevy_egui` needs `--cfg web_sys_unstable_apis`.
**Two things the human must decide before it lands:**
* **#11 is `state/proposed` and this goes past its stated shape.** It was written
around the getrandom error alone. The tokio removal is a change to another
crate, not CI config — look at that one specifically.
* **It will not turn the job green.** It unblocks two steps that have never run:
`jetli/trunk-action@v0.5.0` and `trunk build --release`. The action's bundled
`dist/index.js` names `x86_64-unknown-linux-gnu` once and `aarch64` **never**,
so it will fetch an x86_64 binary onto the aarch64 runner. Upstream *does*
publish `trunk-aarch64-unknown-linux-gnu.tar.gz` — so this is "the action
cannot find it", not "trunk is unavailable on arm". Replacing the install step
means picking a version and a method: a decision, not a slip-in.
## 7. The method lessons — the most transferable part
One failure shape recurred **five times in two days**, across two agents and the
human's assistant. Every instance is: **a property inferred from something
adjacent to it, rather than tested directly.**
| what was inferred | from what | how it failed |
|---|---|---|
| `main` is protected | the settings page | merging ignores the push whitelist; both agents could have approved each other |
| the desktop can't reach Gitea | `curl` being refused | that was a *permission prompt*, not the network |
| the tool creates 12 labels | `grep -c '^mklabel'` | one match was the function **definition** |
| Gitea is not on the Pi | a DNS lookup | it is, published through a VPS |
| CI's clippy == mine | **identical rustfmt output** | rustfmt is output-stable by design; clippy moves lints between groups |
The last one is the sharpest. `rustfmt 1.8.0` and `1.9.0`, nine months apart,
both produce **774** hunks on this tree — so formatting parity carries *no*
information about which clippy ran. From it I concluded CI's green must be cached
or ungated ("the frozen splash again"). It was neither: `collapsible_else_if` is
`warn` on 1.92.0 and **`allow`** on 1.98.1, which is what the runner has.
> **The check that worked, every time, was counting the same thing with the same
> tool against a baseline.**
Three rules now in `PROTOCOL.md`, each earned:
* **A finding reaches `main` before the code that cites it.**
* **A check may only soften against a condition it can test** — *can this branch
tell the difference between "not yet" and "no longer"?*
* **If you are writing the softening in the same commit as the check, the thing
you want is an issue, not a flag.**
And the ordering constraint that is not obvious: **the cheap-looking fix for #12
is the expensive one.** A whole-tree reformat before #7 and #8 return would put a
conflict in every file of 861 commits and make the reviews those items exist to
enable unreadable. Measured: 133 files carrying 81% of the debt cannot collide;
the real blocker is **21 files**. Land #7/#8 first, then sweep once.
## 8. Resuming — concrete first steps
```bash
git fetch origin
git switch fix/clippy-lints # PR #14's head, d8807c4f
python3 tools/gitea-protect --verify # expect: protection holds. rc=0
```
Then, in order of what is actually blocking:
1. **Merge #10 and #14** — human, with the admin override. Everything else is
downstream: the loop agents clone `main`, which has none of this.
2. **Decide #11's scope**, then fetch `/tmp/sylph-wasm-compile.bundle` from the
Pi and push it.
3. **Decide the trunk-action replacement** (§6).
4. **Then Phase 7** of [`GITEA-SETUP.md`](GITEA-SETUP.md): start the **decoder
alone**, watch one full iteration — notifications polled, a PR rather than a
bare push, no merge button — before starting the port.
### Credentials, all on the desktop, all `chmod 600`
```
~/.sylph-git-credentials write:repository — the push credential
~/.sylph-gitea-token-decoder the decoder agent's, four scopes
~/.sylph-gitea-token-port the port agent's, four scopes
~/.sylph-claude-token long-lived Claude auth for the containers
```
`~/.sylph-gitea-api-token` (`write:issue`) lives **on the Pi**, because that is
where `tools/gitea-setup` runs. Never paste any of them into chat.
⚠️ **This desktop was updated to `rustc 1.98.1` on 2026-09-06** to match the
runner. Anything verified here *before* that ran on 1.92.0 — the fmt counts (774)
and test counts (207/0/14) matched CI exactly and therefore carry; a clippy
result from before does not.
## 9. Standing constraints
* **Never commit game content**, under any directory name. 545 MB reached a
branch on 2026-09-04 under a name the ignore list did not happen to mention.
`.gitignore` now describes the *shape* (`/export*/`, media by extension).
* **Agents never merge**, never push to `main`, never rewrite history. One
force-push was authorised, once, to fix commit authorship before merge — a
recorded exception, not a precedent.
* **The Explorer shows static data only** — the ISO, the embedded PE, savegames.
Never anything generated by a Sylpheed run.
* **Never self-screenshot**; ask the human to capture. Ask before a
watch-and-verify `--ui` launch.
* **Never judge emulator crash or stability from a Bash-launched run.**
## 10. The second machine — measured on `fabi-MS-7C37`, 2026-09-06
**§§19 were written on `fabi-Hyrican-PC`.** This section was written on the
other desktop, and every line is a command run *here*. Nothing above is carried
across untested — §7 is the reason.
Everything §§45 say about the **server** checks out exactly: 13 open issues,
#10 and #14 open and `mergeable`, Gitea 1.25.5. What does not transfer is the
**box**.
### What is different here, and what it costs
| § | says | here |
|---|---|---|
| 8 | four `~/.sylph-*` credentials, `chmod 600` | **none exist.** `~/.git-credentials` holds a `fabi@git.mc02.dev` token that reads `branch_protections` — an endpoint both agent tokens are refused on — so it is not an agent token. Whether it can *push* is untested |
| 8.2 | fetch `/tmp/sylph-wasm-compile.bundle` from the Pi | **`raspberrypi.fritz.box` does not resolve here**, and there is no host key for it. `ba6c5da` is unreachable from this machine |
| 8 | "updated to `rustc 1.98.1` … to match the runner" | that was the *other* desktop. Here `stable` = **1.90.0**, with a `1.92.0` also installed |
| 5 | the agent images | no `sylph-decoder` / `sylph-port` image on this box |
So of §8's four steps, only **1** (the human's merges) and **3** (the
trunk-action decision, an edit to `ci.yml`) can be done from here. **2 and 4
cannot, at all.**
### 🔴 `git clone` of this repository does not work
Three attempts died on `GnuTLS recv error (-9)` / `Recv failure: Connection
reset by peer`, at 135 MB, 4.6 MB and 73 MB. A default clone fetches **every**
branch, and `auto/port-p6-audio`'s history still carries the 545 MB of game
content §9 describes — gone from the tree, still in the pack.
What works, in seconds:
```bash
git -c http.version=HTTP/1.1 clone --filter=blob:none \
https://git.mc02.dev/fabi/Sylpheed.git
```
Blobs fault in on demand. The tree is **108 MB in 1 007 files** (98 MB of it
`docs/re/captures/`), and it was verified byte-for-byte against
`git ls-tree -r -l` — worth doing, because the clone printed *"checkout failed"*
partway and then recovered silently. ⚠️ One cost: `check-citations` reaches into
peer branches, so on a blobless clone it faults blobs over that same flaky link
and takes minutes rather than seconds.
### The numbers that carry, and the one that does not
Expected values taken from §§45 *before* running, per R2:
| | expected | measured here | |
|---|---|---|---|
| `gitea-protect --verify` | protection holds | **holds**, 10/10, rc=0 | ✅ carries |
| `cargo fmt --all -- --check` | 774 hunks | **774**, across **154 files** | ✅ carries |
| `check-citations --for-merge` | 19 | **19**, rc=1 | ✅ carries |
| `cargo test --workspace` | 207 / 0 / 14, 30 suites | **207 / 0 / 14, 30** | ✅ carries — see below |
| `cargo clippy --workspace -- -D warnings` | *not comparable* | **exit 101** | 🔴 diverges |
154 files corroborates rather than adds: §7's split of the fmt debt into "133
that cannot collide" and "21 that are the real blocker" sums to exactly it.
`gitea-protect --verify` needs `SYLPH_GIT_CREDENTIALS=~/.git-credentials` here,
since its default is one of the four missing files.
### 🔴 The test count carries — and that is what is wrong with it
It matched to the unit. It should not be read as the two runs having done the
same thing, because they did not.
**15 files** under `crates/sylpheed-formats/tests/*_disc.rs` resolve the disc
through a `disc_root()` whose second branch is a **hardcoded absolute path**
`/home/fabi/RE - Project Sylpheed/Project Sylpheed - Arc of Deception (USA,
Europe) (En,Ja)`. So `unset SYLPHEED_DISC` does **not** disable them: that
directory exists on this box, and the disc suites *ran*.
| | CI (job 794) | here |
|---|---|---|
| test **execution** wall time | **2.4 s**, slowest suite 0.29 s | **1 936 s**, `mesh_consistency_disc` alone **1 220 s** |
| tally | 207 / 0 / 14, 30 suites | **identical** |
Identical because the skip path is `eprintln!("SKIP: …")` **plus an early return
from a test that still passes**. A skipped disc test and a fully exercised one
both score `1 passed`. And the message is invisible either way — `cargo test`
captures a passing test's stderr, so *neither* log contains a `SKIP:` line. The
absence of one proves nothing; only the clock separated these two runs.
So ask the question this project keeps having to ask — **what would this check
still report if the corpus were entirely absent?** — and the answer is
`207 / 0 / 14`.
Two consequences, and the first is good news:
* this run is **strictly stronger evidence than CI's**: 207 passed with the disc
corpus actually exercised, on x86_64, at `593b378`.
* `SYLPHEED_DISC` looks like the control and is not one. Whether the disc suites
run is a property of *the machine's directory layout*, invisible in the command
and in the output. Worth an issue — it is the `.gitignore` lesson again,
**naming an instance instead of the condition.**
📌 **A sixth instance for §7, and it is mine.** I inferred "the counts cannot
match" from "the fallback resolves" — an adjacent property, never tested — and
wrote it into this section before the run finished. The run returned 207 / 0 / 14.
The correction was the same as every other time in that table: run it, and count.
### 🔴 The clippy gate genuinely disagrees between the two toolchains
This is **#15 ceasing to be theoretical**, and it needs stating carefully,
because it is §7's lesson 5 arriving from the other side.
Both sides measured with the same command, both versions read rather than assumed:
* **the runner** — `rustc 1.98.1 (48a229cea 2026-09-01)`, read out of job 794's
own log. `cargo clippy --workspace -- -D warnings` finishes in 9.26 s with no
lint. Native is green on runs **206, 207, 208 and 209** — four consecutive,
not three.
* **here** — `rustc 1.90.0` / `clippy 0.1.90`. The same command exits **101**, on
exactly one lint:
```
error: parameter is only used in recursion
--> crates/sylpheed-formats/src/vfs.rs:85:10
= note: `-D clippy::only-used-in-recursion` implied by `-D warnings`
```
Without `-D warnings` it is a warning and clippy exits 0 — so the disagreement
sits exactly at the gate.
**What this does not mean.** It does not mean #14 is wrong, and it does not mean
CI's green is cached or ungated. That is the inference §7 records as the sharpest
of its five failures, and the evidence points the other way: the runner's log
shows the step running, on this code, clean. `d8807c4` already collapsed one
`else { if }` *"so both toolchains agree"* — this is the same class, one lint on.
What it establishes is narrower and more useful: **the gate's verdict depends on
which stable happened to be current**, and there is now a named reproducible case
rather than an argument. That is #15's evidence.
📌 For whoever picks up #13/#14: *"clippy is clean"* is not a property of the
tree, it is a property of the tree **and** a toolchain. Until #15 pins one, say
which one you ran.
### Refutation attempted, and survived
Per the adversarial duty — §6's claim that `sylpheed-formats` declares `tokio` as
a normal dependency **it never uses**. It *is* referenced, in `ship.rs` and
`xiso.rs`, which looked like a refutation. It is not: every one of those sits
inside a `#[cfg(test)]` module (`ship.rs:497`, `xiso.rs:178`). The library's
non-test code does not use tokio, so moving it to `dev-dependencies` is sound and
the `examples/` targets keep compiling. **Claim survives** — recorded because a
survived challenge is stronger than an unchallenged one, not because it changed
anything.

View File

@@ -167,7 +167,7 @@ neither its own branch nor `main`.
## Checks that were kind once
Two rules that look unrelated and are the same failure.
Three instances now, and they are the same failure.
**A check may only soften against a condition it can test.**
@@ -189,6 +189,45 @@ a collaborator exists, so the "yet" was never needed.
stays. That is why they survive review, and why the smell is worth naming:
*leniency with an expiry date nobody set.*
### The third instance was authored dirty, not decayed into
The two above were **correct when written**. The third was not, and it is worth
separating because it arrives by a different route and is caught at a different
moment.
CI's `Clippy` step turned out never to have run — the toolchain shipped without
the component, so `cargo clippy -- -D warnings` died on *"not installed"* on
every commit in the repo's history. Fixing that is two lines. But the tree is
not clippy-clean: the build already emits ~13 rustc warnings that `-D warnings`
promotes to errors. So the fix and the first red result arrive together, and the
first draft paired the two-line fix with `continue-on-error: true` and a comment
saying *delete this line once the debt is paid* — which is precisely an expiry
date nobody set. It was reverted within the hour, on reading #12's own closing
line ruling the same shape out for rustfmt.
The difference that matters:
| | first two | third |
|---|---|---|
| when it was wrong | became wrong later | wrong on the first commit |
| what caused it | the world moved | the tree was already dirty |
| what catches it | auditing old allowances | noticing the impulse at the keyboard |
**This is the default way a check gets written when the tree is not clean yet.**
Not a rare slip — the ordinary shape of the first draft. Whenever a real check
goes in against a tree that does not yet pass it, the softening is *right there*,
it looks like pragmatism, and it comes with a sincere comment promising removal.
The mechanical test still catches it after the fact. The earlier tell is this:
> **If you are writing the softening in the same commit as the check, the thing
> you want is an issue, not a flag.**
A red check that measures something is worth more than a green one that measures
nothing, and it is worth strictly more than a green one that *used to* measure
something. Land the check gating, let it be red, and scope the debt where it can
be read, argued with and closed — #12 for rustfmt, #13 for clippy. An issue has
the expiry date the flag never gets.
`share put <file> --note "…" --for port` records the sender, the time, **the
commit they were on**, and whether their tree was dirty. A capture with no
provenance is not evidence, it is a picture.