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}