Skip to main content

rustynes_mappers/
tier.rs

1//! Mapper accuracy tiering (v1.2.0).
2//!
3//! `RustyNES` classifies every supported mapper family into one of three tiers.
4//! The tier is an **honesty marker**, not a behavioural one: a mapper's runtime
5//! behaviour is identical regardless of tier — the tier records only how much
6//! external evidence backs its correctness, so accuracy claims stay precise as
7//! the long-tail mapper set grows.
8//!
9//! - [`MapperTier::Core`] — the original spec-implemented families that are
10//!   gated by the `AccuracyCoin` / commercial-ROM oracle suites.
11//! - [`MapperTier::Curated`] — long-tail families added with concrete game
12//!   demand plus a redistributable fixture or spec; register-decode unit-tested
13//!   and boot-smoked (oracle-gated where a free fixture exists).
14//! - [`MapperTier::BestEffort`] — long-tail families ported from reference
15//!   emulators (`GeraNES` / `Mesen2`) that have no redistributable test fixture.
16//!   Register-decode unit-tested only, and **explicitly excluded** from the
17//!   `AccuracyCoin` / oracle gate.
18//!
19//! The load-bearing invariant — *no `BestEffort` mapper may back a ROM in the
20//! accuracy oracle corpus* — is enforced at the classifier level: `BestEffort`
21//! is structurally never accuracy-gated, the three tier id-sets are disjoint,
22//! and the byte-oracle corpus references only Core/Curated mappers by
23//! construction. This [`mapper_tier`] classifier is the single source of truth.
24//! See `docs/adr/0011-mapper-tiering.md`.
25
26/// Accuracy-evidence tier for a supported mapper family. See the module docs.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum MapperTier {
29    /// Original families, `AccuracyCoin` / oracle-gated.
30    Core,
31    /// Curated long-tail: demand + redistributable fixture/spec, unit + smoke tested.
32    Curated,
33    /// Best-effort long-tail: reference-ported, register-decode tested only,
34    /// never part of the accuracy gate.
35    BestEffort,
36}
37
38impl MapperTier {
39    /// Human-readable tier name (for docs generation, UI badges, and logs).
40    #[must_use]
41    pub const fn name(self) -> &'static str {
42        match self {
43            Self::Core => "Core",
44            Self::Curated => "Curated",
45            Self::BestEffort => "BestEffort",
46        }
47    }
48
49    /// Whether this tier is covered by the `AccuracyCoin` / commercial-ROM oracle
50    /// gate. `Core` and `Curated` are; `BestEffort` is not.
51    #[must_use]
52    pub const fn is_accuracy_gated(self) -> bool {
53        matches!(self, Self::Core | Self::Curated)
54    }
55}
56
57/// Classify a mapper family by its iNES id and NES 2.0 submapper into a
58/// [`MapperTier`].
59///
60/// Returns `None` for any id that [`crate::parse`] does not support — the two
61/// sets are kept in lockstep, so a supported mapper always has a tier and an
62/// unsupported one never does.
63///
64/// The submapper argument was reserved from the start for the case where one
65/// variant of a family carries less evidence than the family as a whole.
66/// **Mapper 176 submapper 2 is the first family to use it** (v2.3.4): the 8025's
67/// submappers are documented as mutually incompatible boards, and WAIXING-FS005
68/// is a distinct board whose evidence is its own — see the match arm below.
69#[must_use]
70pub const fn mapper_tier(id: u16, submapper: u8) -> Option<MapperTier> {
71    // --- Per-submapper overrides, applied before the family classification.
72    //
73    // Mapper 176 submapper 2 (WAIXING-FS005/FS006) is NOT the FK23C the rest of
74    // the family is oracle-gated on; it is a separate board added in v2.3.4. Of
75    // its three known dumps two boot correctly and one renders no tiles, so it
76    // has register-decode unit tests but no clean oracle across its own corpus.
77    // Claiming the family's `Curated` for it would assert evidence that does not
78    // exist, which is precisely what the tiering is here to prevent.
79    if id == 176 && submapper == 2 {
80        return Some(MapperTier::BestEffort);
81    }
82    // v2.9.6 "Roster": mapper 12 submapper 1 is the Magic Card 4M disk
83    // extraction, a different device that `parse` does not support.
84    if id == 12 && submapper != 0 {
85        return None;
86    }
87    // Mapper 4 submapper 3, Acclaim's MC-ACC. v2.9.6 wrote it BestEffort: the
88    // falling-edge /8 counter is on the MMC3 page, but the prescaler reset and
89    // phase come from a forum measurement (`m004_mmc3.rs`, `Mmc3Variant::McAcc`).
90    // v2.9.7 promoted it to Curated on real games. Six Acclaim titles with NES
91    // 2.0 submapper-3 headers boot cleanly, and four of them (Alien 3,
92    // Terminator 2, The Incredible Crash Dummies, WWF King of the Ring) draw
93    // their status bars and title cards only under this model; forced to
94    // standard MMC3 they corrupt. That needed the PPU's v2.9.7 A12 fix: before
95    // it, the /8 was fed one edge per line instead of eight, and those four were
96    // broken. Curated, not the family's Core: no AccuracyCoin-level oracle
97    // covers it.
98    if id == 4 && submapper == 3 {
99        return Some(MapperTier::Curated);
100    }
101    // Mapper 91 submapper 1's M2 IRQ is described without saying when it
102    // asserts (`m091_jy_sf3.rs`), so it carries less evidence than
103    // submapper 0.
104    if id == 91 && submapper == 1 {
105        return Some(MapperTier::BestEffort);
106    }
107    match id {
108        // --- Tier 0 / Core: the original 51 families (AccuracyCoin/oracle-gated).
109        0 | 1 | 2 | 3 | 4 | 5 | 7 | 9 | 10 | 11 | 13 | 16 | 18 | 19 | 21 | 22 | 23 | 24 | 25
110        | 26 | 32 | 33 | 34 | 48 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 73 | 75 | 78 | 80
111        | 82 | 85 | 87 | 88 | 89 | 93 | 99 | 118 | 119 | 151 | 152 | 159 | 184 | 206 | 210 => {
112            Some(MapperTier::Core)
113        }
114
115        // --- Tier 1 / Curated: discrete-logic long-tail boards + the
116        // v2.1.0 "Fathom" F3 promotion batch (86 previously-BestEffort families
117        // with a cleanly-booting staged commercial-ROM dump — 57 already-staged +
118        // 29 sourced from GoodNES v3.23b — wired into a byte-identity boot-snapshot
119        // oracle in `external_extended.rs`; ADR 0011). Each is register-decode
120        // unit-tested AND oracle-gated.
121        15 | 28 | 30 | 31 | 35 | 36 | 38 | 40 | 41 | 42 | 44 | 46 | 49 | 51 | 52 | 56 | 57 | 58
122        | 60 | 61 | 62 | 63 | 72 | 76 | 77 | 79 | 86 | 90 | 92 | 94 | 95 | 96 | 97 | 101 | 107
123        | 112 | 113 | 115 | 120 | 132 | 133 | 134 | 136 | 137 | 138 | 139 | 140 | 141 | 142
124        | 143 | 145 | 146 | 147 | 148 | 149 | 150 | 156 | 162 | 164 | 176 | 177 | 178 | 180
125        | 185 | 189 | 193 | 200 | 201 | 202 | 203 | 204 | 205 | 209 | 211 | 212 | 213 | 214
126        | 218 | 221 | 225 | 226 | 227 | 229 | 231 | 232 | 233 | 234 | 240 | 241 | 242 | 244
127        | 245 | 246 | 250 | 253 => Some(MapperTier::Curated),
128
129        // --- v2.9.6 "Roster": families written from a NESdev page that gives
130        // exact register masks (ADR 0011's "precise decode spec"), each with
131        // register-decode unit tests and a synthetic boot fixture, plus
132        // GTROM (111), promoted from BestEffort once the board matched its
133        // page (register window, bonus RAM, self-flashing) and a CC0 fixture
134        // pinned it. None has a redistributable commercial ROM.
135        12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 => {
136            Some(MapperTier::Curated)
137        }
138
139        // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families
140        // that lack a *cleanly-booting* redistributable ROM dump (so they cannot
141        // be honestly oracle-gated and stay register-decode + save-state
142        // unit-tested only). After the v2.1.0 "Fathom" F3 sweep promoted the 86
143        // families with a booting staged/GoodNES dump, these are what is left:
144        // the NES 2.0 high-id boards
145        // (268/286/289/290/299/301/303/305/306/312/320/336/348/349/366/513 —
146        // GoodNES v3.23b predates NES 2.0 headers, so no dump decodes to these
147        // ids); 8 boards with no matching cart in the collection (29 Sealie
148        // RET-CUFROM, 39 Subor BNROM, 81 NTDEC Super Gun, 104 Golden Five,
149        // 174 multicart, 179 Hengedianzi, 238 MMC3+$4020-security, 261 BMC); and
150        // 50, whose ONLY available dump (SMB2j FDS-conversion) halts ~18 frames
151        // in — a jammed boot is not honest Curated oracle evidence. NOT
152        // accuracy-gated (ADR 0011). 111 GTROM left this list in v2.9.6.
153        //
154        // v2.3.4 adds 154 (NAMCOT-3453) and 243 (Sachen SA-020A). Both became
155        // visible only once the coverage harness started applying the per-game
156        // database, which is what routes `Devil Man` from its m88 header to 154
157        // and the Sachen 74LS374N set from m150 to 243. Their dumps are staged
158        // but not redistributable, so neither can be honestly oracle-gated.
159        29 | 39 | 50 | 81 | 104 | 154 | 174 | 179 | 238 | 243 | 261 | 268 | 286 | 289 | 290
160        | 299 | 301 | 303 | 305 | 306 | 312 | 320 | 336 | 348 | 349 | 366 | 513 => {
161            Some(MapperTier::BestEffort)
162        }
163
164        // --- v2.9.6 "Roster" BestEffort: pages that give only Disch's notes
165        // (47, 191, 194) or register masks marked "probably" (121).
166        47 | 121 | 191 | 194 => Some(MapperTier::BestEffort),
167
168        _ => None,
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    /// The first `mapper_tier` branch that depends on the submapper. Nothing
177    /// else fails if the early return is dropped during a later edit of the
178    /// match arms, so pin both halves: the variant is downgraded, the family is
179    /// not.
180    #[test]
181    fn m176_submapper_2_is_best_effort_but_the_family_stays_curated() {
182        assert_eq!(mapper_tier(176, 2), Some(MapperTier::BestEffort));
183        assert!(!mapper_tier(176, 2).unwrap().is_accuracy_gated());
184        for sub in [0u8, 1, 3, 255] {
185            assert_eq!(
186                mapper_tier(176, sub),
187                Some(MapperTier::Curated),
188                "submapper {sub} must keep the family tier"
189            );
190        }
191    }
192
193    /// 154 and 243 landed in v2.3.4 and must be classified, or `parse` supports
194    /// a mapper the tier table does not know -- the invariant this module exists
195    /// to hold.
196    #[test]
197    fn v234_additions_are_classified_best_effort() {
198        for id in [154u16, 243] {
199            assert_eq!(
200                mapper_tier(id, 0),
201                Some(MapperTier::BestEffort),
202                "mapper {id}"
203            );
204        }
205    }
206
207    /// The 51 Core (Tier-0) families that shipped before v1.2.0. This list is
208    /// the contract: every id here must classify as `Core`, and the count must
209    /// stay at 51 until the curated/best-effort batches deliberately extend it.
210    const CORE_IDS: &[u16] = &[
211        0, 1, 2, 3, 4, 5, 7, 9, 10, 11, 13, 16, 18, 19, 21, 22, 23, 24, 25, 26, 32, 33, 34, 48, 64,
212        65, 66, 67, 68, 69, 70, 71, 73, 75, 78, 80, 82, 85, 87, 88, 89, 93, 99, 118, 119, 151, 152,
213        159, 184, 206, 210,
214    ];
215
216    #[test]
217    fn all_core_ids_classify_as_core() {
218        for &id in CORE_IDS {
219            assert_eq!(
220                mapper_tier(id, 0),
221                Some(MapperTier::Core),
222                "mapper {id} should be Tier-0 Core"
223            );
224        }
225    }
226
227    #[test]
228    fn core_family_count_is_fifty_one() {
229        assert_eq!(
230            CORE_IDS.len(),
231            51,
232            "the Core tier is the original 51 families"
233        );
234    }
235
236    /// The v1.2.0 curated (Tier-1) discrete-logic batch. Must stay in
237    /// lockstep with the `parse()` match arms for those ids.
238    const CURATED_IDS: &[u16] = &[
239        15, 28, 30, 31, 35, 36, 38, 40, 41, 42, 44, 46, 49, 51, 52, 56, 57, 58, 60, 61, 62, 63, 72,
240        76, 77, 79, 86, 90, 92, 94, 95, 96, 97, 101, 107, 112, 113, 115, 120, 132, 133, 134, 136,
241        137, 138, 139, 140, 141, 142, 143, 145, 146, 147, 148, 149, 150, 156, 162, 164, 176, 177,
242        178, 180, 185, 189, 193, 200, 201, 202, 203, 204, 205, 209, 211, 212, 213, 214, 218, 221,
243        225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253,
244        // v2.9.6 "Roster".
245        12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249,
246    ];
247
248    #[test]
249    fn all_curated_ids_classify_as_curated() {
250        for &id in CURATED_IDS {
251            assert_eq!(
252                mapper_tier(id, 0),
253                Some(MapperTier::Curated),
254                "mapper {id} should be Tier-1 Curated"
255            );
256        }
257    }
258
259    /// The best-effort (Tier-2) sweeps: the v1.2.0 discrete / Sachen /
260    /// multicart batches, the v1.3.0 "Bedrock" Workstream D1 batch, the
261    /// v1.4.0 "Fidelity" Workstream G batch, the v1.5.0 "Lens" Workstream F
262    /// batch, the v1.6.0 "Studio" J.Y. Company ASIC (90/209/211 + the 35
263    /// sibling), the v1.6.0 "Studio" Workstream E batch (MMC3-clones, Sachen
264    /// 8259 A/B/C, discrete multicarts), the v1.7.0 "Forge" Workstream G1
265    /// reusable-ASIC BMC/pirate batch (FK23C, COOLBOY/MINDKIDS, Sachen
266    /// 9602/3011, Waixing 164/253/286, Kaiser 56/142/303/305/306/312, and BMC
267    /// multicarts 261/289/320/336/349), and the v1.8.9 "Backlog" beta.6
268    /// NTDEC/TXC/BMC multicart batch (193/204/221/299).
269    const BEST_EFFORT_IDS: &[u16] = &[
270        29, 39, 50, 81, 104, 174, 179, 238, 261, 268, 286, 289, 290, 299, 301, 303, 305, 306, 312,
271        320, 336, 348, 349, 366, 513, // v2.9.6 "Roster":
272        47, 121, 191, 194,
273    ];
274
275    #[test]
276    fn all_best_effort_ids_classify_as_best_effort() {
277        for &id in BEST_EFFORT_IDS {
278            assert_eq!(
279                mapper_tier(id, 0),
280                Some(MapperTier::BestEffort),
281                "mapper {id} should be Tier-2 BestEffort"
282            );
283        }
284    }
285
286    #[test]
287    fn best_effort_is_not_accuracy_gated() {
288        for &id in BEST_EFFORT_IDS {
289            assert!(
290                !mapper_tier(id, 0).unwrap().is_accuracy_gated(),
291                "BestEffort mapper {id} must not be accuracy-gated"
292            );
293        }
294    }
295
296    #[test]
297    fn tiers_are_pairwise_disjoint() {
298        // No mapper id may appear in more than one tier — a copy-paste guard for
299        // the three classifier arms.
300        for &id in CURATED_IDS {
301            assert!(!CORE_IDS.contains(&id), "id {id} in both Core and Curated");
302            assert!(
303                !BEST_EFFORT_IDS.contains(&id),
304                "id {id} in both Curated and BestEffort"
305            );
306        }
307        for &id in BEST_EFFORT_IDS {
308            assert!(
309                !CORE_IDS.contains(&id),
310                "id {id} in both Core and BestEffort"
311            );
312        }
313    }
314
315    /// v2.9.6: the per-submapper verdicts. Mapper 12 submapper 1 is not
316    /// supported at all; mapper 91 submapper 1 carries less evidence.
317    #[test]
318    fn roster_submapper_overrides() {
319        assert_eq!(mapper_tier(12, 0), Some(MapperTier::Curated));
320        assert_eq!(mapper_tier(12, 1), None);
321        assert_eq!(mapper_tier(91, 0), Some(MapperTier::Curated));
322        assert_eq!(mapper_tier(91, 1), Some(MapperTier::BestEffort));
323        assert_eq!(
324            mapper_tier(4, 3),
325            Some(MapperTier::Curated),
326            "MC-ACC, v2.9.7"
327        );
328        assert_eq!(mapper_tier(4, 1), Some(MapperTier::Core), "MMC6");
329    }
330
331    #[test]
332    fn unsupported_id_has_no_tier() {
333        // A representative unsupported id; mapper 255 is not implemented.
334        assert_eq!(mapper_tier(255, 0), None);
335    }
336
337    #[test]
338    fn core_tier_is_accuracy_gated() {
339        assert!(MapperTier::Core.is_accuracy_gated());
340        assert!(MapperTier::Curated.is_accuracy_gated());
341        assert!(!MapperTier::BestEffort.is_accuracy_gated());
342    }
343}