From b5809dbc3e34e8c1d98305265687179cfbbcf041 Mon Sep 17 00:00:00 2001 From: Darkaxt Date: Fri, 14 Aug 2026 19:41:17 +0300 Subject: [PATCH 1/3] Resolve compiled Pokedex descriptions --- .../parser/catalog/SpeciesIndexResolver.kt | 10 +- .../descriptions/DescriptionResolver.kt | 1 + .../parser/family/SemanticDomainStrategy.kt | 102 ++++++++++++++---- .../parse/GbaPublishedHeaderResolver.kt | 20 +++- .../parser/parse/ParserOrchestrator.kt | 3 +- .../parse/SpeciesSemanticDomainResolver.kt | 23 +++- .../descriptions/DescriptionLiveRomTest.kt | 75 +++++++++++++ .../descriptions/DescriptionResolverTest.kt | 15 ++- .../parse/GbaPublishedHeaderResolverTest.kt | 19 ++++ 9 files changed, 236 insertions(+), 32 deletions(-) diff --git a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/catalog/SpeciesIndexResolver.kt b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/catalog/SpeciesIndexResolver.kt index f3bfca97..847267f9 100644 --- a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/catalog/SpeciesIndexResolver.kt +++ b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/catalog/SpeciesIndexResolver.kt @@ -551,8 +551,14 @@ object SpeciesIndexResolver { } else if (oneDefectCompositionSupport) { summary.distinctCount.toDouble() / count } else if (descriptionCount != null && descriptionCount > 1) { - if (summary.maximum >= descriptionCount) return null - summary.distinctCount.toDouble() / (descriptionCount - 1) + if (summary.maximum >= descriptionCount) { + // A partial Pokédex-entry table must not invalidate an independently compiled + // species-to-Dex map. IDs beyond the table remain navigable species whose + // description capability truthfully stays unresolved. + summary.distinctCount.toDouble() / summary.maximum + } else { + summary.distinctCount.toDouble() / (descriptionCount - 1) + } } else { summary.distinctCount.toDouble() / summary.maximum } diff --git a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionResolver.kt b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionResolver.kt index 9e5f2e1d..23704c1f 100644 --- a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionResolver.kt +++ b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionResolver.kt @@ -137,6 +137,7 @@ class DescriptionResolver( ) } referenceIndex?.targets?.forEach { (root, evidence) -> + if (!looksLikeDescriptionStart(session.rom, root)) return@forEach canonicalLayouts(root, expectedSpeciesCount).forEach { layout -> yield( DescriptionProposal.Probe( diff --git a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/family/SemanticDomainStrategy.kt b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/family/SemanticDomainStrategy.kt index 8d9e8f59..62083b22 100644 --- a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/family/SemanticDomainStrategy.kt +++ b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/family/SemanticDomainStrategy.kt @@ -8,6 +8,7 @@ import com.enrpau.dualscreendex.parser.model.TableLayout import com.enrpau.dualscreendex.parser.model.ValidationEvidence import com.enrpau.dualscreendex.parser.parse.DatasetResolvers import com.enrpau.dualscreendex.parser.parse.Gen3PublishedPartialBaseStatsResolver +import com.enrpau.dualscreendex.parser.parse.GbaPublishedHeaderResolver import com.enrpau.dualscreendex.parser.parse.PokeemeraldExpansionResolver import com.enrpau.dualscreendex.parser.parse.selectAbilityNameEvidence import com.enrpau.dualscreendex.parser.parse.compiledAbilityNameStride @@ -77,7 +78,8 @@ internal class SemanticDomainStrategy : FamilyProbePhaseStrategy { val rawCore = requireNotNull(state.coreDatasets) as CoreDatasetsPhaseResult.Resolved val descriptionResolution = resolveDescriptions(session, definition, identity, rawCore) val descriptions = descriptionResolution.evidence - val descriptionsLayout = resolvedLayout(rawCore.candidateTables.descriptions, descriptions) + val descriptionsLayout = descriptionResolution.resolved?.table?.toTableLayout() + ?: resolvedLayout(rawCore.candidateTables.descriptions, descriptions) val core = promotePublishedPartialStats( session, definition, @@ -145,37 +147,68 @@ internal class SemanticDomainStrategy : FamilyProbePhaseStrategy { identity: IdentityRootsPhaseResult.Resolved, core: CoreDatasetsPhaseResult.Resolved, ): ResolvedDescriptionEvidence { - val evidence = validateDescriptions(session, definition, identity, core) - if (definition.formatGeneration != 3 || identity.expansion != null || !evidence.compatible) { - return ResolvedDescriptionEvidence(evidence, null) + if (definition.formatGeneration != 3 || identity.expansion != null) { + return ResolvedDescriptionEvidence(validateDescriptions(session, definition, identity, core), null) } - val selected = resolvedLayout(core.candidateTables.descriptions, evidence) - ?.toDescriptionTableLayout() - ?: return ResolvedDescriptionEvidence( - evidence.copy( - compatible = false, - reasons = evidence.reasons + - "selected description ABI could not be represented by the typed codec", - ), - null, + val legacyEvidence = validateDescriptions(session, definition, identity, core) + if (legacyEvidence.compatible) { + val selected = resolvedLayout(core.candidateTables.descriptions, legacyEvidence) + ?.toDescriptionTableLayout() + ?: return ResolvedDescriptionEvidence( + legacyEvidence.copy( + compatible = false, + reasons = legacyEvidence.reasons + + "selected description ABI could not be represented by the typed codec", + ), + null, + ) + val selectedResolved = resolveTypedDescriptions( + session = session, + expectedSpeciesCount = core.speciesCount ?: selected.count.toInt(), + selectedLayout = selected, ) - val resolution = DescriptionResolver().resolve( + return if (selectedResolved != null) { + ResolvedDescriptionEvidence(legacyEvidence, selectedResolved) + } else { + ResolvedDescriptionEvidence( + legacyEvidence.copy( + compatible = false, + reasons = legacyEvidence.reasons + "selected description layout failed typed resolution", + ), + null, + ) + } + } + val publishedPokedexCount = GbaPublishedHeaderResolver.resolve(session.rom).pokedexCount + val expectedCount = publishedPokedexCount + ?: core.speciesCount + ?: identity.baseProfile?.internalSpeciesCount + ?: 412 + val resolved = resolveTypedDescriptions( session = session, - expectedSpeciesCount = core.speciesCount ?: selected.count.toInt(), - selectedLayout = selected, + expectedSpeciesCount = expectedCount, + ) ?: return ResolvedDescriptionEvidence(legacyEvidence, null) + val typedTable = resolved.table.toTableLayout() + val evidence = DatasetResolvers.gen3Descriptions( + session = session, + speciesCount = typedTable.count, + inherited = typedTable, + codec = identity.codec, ) - val resolved = when (resolution) { - is DatasetResolution.Resolved -> resolution.candidate.layout - is DatasetResolution.Partial -> resolution.candidate.layout - else -> null - } - return if (resolved != null) { + val selected = resolvedLayout(typedTable, evidence) + val agreesWithTypedSelection = selected?.let { + it.offset == typedTable.offset && + it.count == typedTable.count && + it.recordSize == typedTable.recordSize + } == true + return if (evidence.compatible && agreesWithTypedSelection) { ResolvedDescriptionEvidence(evidence, resolved) } else { ResolvedDescriptionEvidence( evidence.copy( compatible = false, - reasons = evidence.reasons + "selected description layout failed typed resolution", + reasons = evidence.reasons + + "typed description selection did not agree with legacy structural validation", ), null, ) @@ -195,6 +228,29 @@ internal class SemanticDomainStrategy : FamilyProbePhaseStrategy { }.getOrNull() } + private fun DescriptionTableLayout.toTableLayout(): TableLayout = TableLayout( + offset = offset.toInt(), + count = count.toInt(), + recordSize = recordSize, + pointerOffsets = pointerOffsets, + ) + + private fun resolveTypedDescriptions( + session: RomAnalysisSession, + expectedSpeciesCount: Int, + selectedLayout: DescriptionTableLayout? = null, + ): ResolvedDescriptionLayout? = when ( + val resolution = DescriptionResolver().resolve( + session = session, + expectedSpeciesCount = expectedSpeciesCount, + selectedLayout = selectedLayout, + ) + ) { + is DatasetResolution.Resolved -> resolution.candidate.layout + is DatasetResolution.Partial -> resolution.candidate.layout + else -> null + } + private data class ResolvedDescriptionEvidence( val evidence: ValidationEvidence, val resolved: ResolvedDescriptionLayout?, diff --git a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/GbaPublishedHeaderResolver.kt b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/GbaPublishedHeaderResolver.kt index 8a8d4668..08c423ec 100644 --- a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/GbaPublishedHeaderResolver.kt +++ b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/GbaPublishedHeaderResolver.kt @@ -14,6 +14,7 @@ internal data class GbaHeaderPointers( val abilities: Int? = null, val abilityDescriptions: Int? = null, val moveData: Int? = null, + val pokedexCount: Int? = null, val publishedDataState: GbaPublishedDataState = GbaPublishedDataState.ABSENT, val publishedDataEvidence: ValidationEvidence? = null, ) @@ -75,14 +76,26 @@ internal object GbaPublishedHeaderResolver { null } + val speciesNames = rom.pointerOrNull(SPECIES_NAMES_SLOT) + val moveNames = rom.pointerOrNull(MOVE_NAMES_SLOT) + val sprites = rom.pointerOrNull(SPRITES_SLOT) + val publishedPokedexCount = if (POKEDEX_COUNT_SLOT <= rom.size - 4) { + rom.u32le(POKEDEX_COUNT_SLOT) + .takeIf { speciesCount != null && it in 2L..speciesCount.toLong() } + ?.toInt() + ?.takeIf { speciesNames != null && moveNames != null && sprites != null } + } else { + null + } return GbaHeaderPointers( - speciesNames = rom.pointerOrNull(SPECIES_NAMES_SLOT), - moveNames = rom.pointerOrNull(MOVE_NAMES_SLOT), - sprites = rom.pointerOrNull(SPRITES_SLOT), + speciesNames = speciesNames, + moveNames = moveNames, + sprites = sprites, baseStats = pointerBlock?.let { rom.pointerOrNull(it) }, abilities = pointerBlock?.let { rom.pointerOrNull(it + 4) }, abilityDescriptions = pointerBlock?.let { rom.pointerOrNull(it + 8) }, moveData = pointerBlock?.let { rom.pointerOrNull(it + 16) }, + pokedexCount = publishedPokedexCount, publishedDataState = publishedDataState, publishedDataEvidence = publishedDataEvidence, ) @@ -136,6 +149,7 @@ internal object GbaPublishedHeaderResolver { private const val SPRITES_SLOT = 0x128 private const val SPECIES_NAMES_SLOT = 0x144 private const val MOVE_NAMES_SLOT = 0x148 + private const val POKEDEX_COUNT_SLOT = 0x168 private const val COMPACT_DATA_ROOT = 0x1AC private const val FREE_SEEN_FLAGS_DATA_ROOT = 0x1B4 private const val STANDARD_DATA_ROOT = 0x1BC diff --git a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/ParserOrchestrator.kt b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/ParserOrchestrator.kt index dd251579..24677248 100644 --- a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/ParserOrchestrator.kt +++ b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/ParserOrchestrator.kt @@ -166,7 +166,8 @@ object ParserOrchestrator { ) } domain.source == SpeciesSemanticDomainSource.STRONGLY_REFERENCED_REGIONAL_ORDER || - domain.source == SpeciesSemanticDomainSource.COMPILED_SPECIES_TO_DEX_MAP -> { + domain.source == SpeciesSemanticDomainSource.COMPILED_SPECIES_TO_DEX_MAP || + domain.source == SpeciesSemanticDomainSource.PUBLISHED_POKEDEX_COUNT -> { byCapability[RomCapability.POKEDEX_DESCRIPTIONS]?.toValidationEvidence()?.let { evidence -> val byDex = layout.resolvedDatasets.descriptions?.catalogDescriptions().orEmpty() val coveredSpeciesIds = RecordMaterializers.species(rom, layout).values diff --git a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/SpeciesSemanticDomainResolver.kt b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/SpeciesSemanticDomainResolver.kt index 243df368..aee90cf5 100644 --- a/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/SpeciesSemanticDomainResolver.kt +++ b/parser-core/src/main/kotlin/com/enrpau/dualscreendex/parser/parse/SpeciesSemanticDomainResolver.kt @@ -10,6 +10,7 @@ import com.enrpau.dualscreendex.parser.model.ValidationEvidence internal enum class SpeciesSemanticDomainSource { STRONGLY_REFERENCED_REGIONAL_ORDER, COMPILED_SPECIES_TO_DEX_MAP, + PUBLISHED_POKEDEX_COUNT, PUBLISHED_EXPANSION_SPECIES_TABLE, NAVIGABLE_SPECIES_FALLBACK, } @@ -158,7 +159,8 @@ internal data class SpeciesSemanticDomain( val physicalGap = expected > 0 && covered < expected val authoritative = authoritativeFallback || source == SpeciesSemanticDomainSource.STRONGLY_REFERENCED_REGIONAL_ORDER || - source == SpeciesSemanticDomainSource.COMPILED_SPECIES_TO_DEX_MAP + source == SpeciesSemanticDomainSource.COMPILED_SPECIES_TO_DEX_MAP || + source == SpeciesSemanticDomainSource.PUBLISHED_POKEDEX_COUNT return incomplete > 0 || evidence.ambiguous || evidence.reviewRecommended || @@ -244,11 +246,24 @@ internal object SpeciesSemanticDomainResolver { descriptionCount = layout.tables.descriptions?.count, ) } + val publishedPokedexCount = GbaPublishedHeaderResolver.resolve(rom).pokedexCount + ?.takeIf { count -> layout.tables.descriptions?.count == count } + val publishedPokedexDomain = if ( + regionalOrder == null && compiledSpeciesToDexMap == null && !expansionDomain && + materialization.indexResolution is SpeciesIndexResolution.Resolved && publishedPokedexCount != null + ) { + navigable.filter { record -> (record.dexNumber.value ?: 0) in 1 until publishedPokedexCount } + .takeIf { records -> records.map { it.dexNumber.value }.distinct().size == records.size } + } else { + null + } val speciesById = species.associateBy { it.id } val expected = regionalOrder?.speciesIds?.mapNotNull { speciesId -> speciesById[speciesId] } ?: if (expansionDomain) { expansionActive + } else if (publishedPokedexDomain != null) { + publishedPokedexDomain } else { navigable.filterNot { it.id in compiledSpeciesToDexMap?.reservedOverflowSpeciesIds.orEmpty() } } @@ -276,6 +291,10 @@ internal object SpeciesSemanticDomainResolver { } else { "" } + } ?: publishedPokedexDomain?.let { + "selected ${it.size} navigable species inside the structurally published " + + "Pokédex count $publishedPokedexCount; excluded " + + "${(rawCount - it.size).coerceAtLeast(0)} internal or out-of-domain slots" } ?: if (expansionDomain) { "selected ${expected.size} positive-Dex named or populated species from the published " + "pokeemerald-expansion gSpeciesInfo table; excluded " + @@ -287,6 +306,8 @@ internal object SpeciesSemanticDomainResolver { SpeciesSemanticDomainSource.STRONGLY_REFERENCED_REGIONAL_ORDER } else if (compiledSpeciesToDexMap != null) { SpeciesSemanticDomainSource.COMPILED_SPECIES_TO_DEX_MAP + } else if (publishedPokedexDomain != null) { + SpeciesSemanticDomainSource.PUBLISHED_POKEDEX_COUNT } else if (expansionDomain) { SpeciesSemanticDomainSource.PUBLISHED_EXPANSION_SPECIES_TABLE } else { diff --git a/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionLiveRomTest.kt b/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionLiveRomTest.kt index 256672dd..1b94473c 100644 --- a/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionLiveRomTest.kt +++ b/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionLiveRomTest.kt @@ -2,7 +2,11 @@ package com.enrpau.dualscreendex.parser.dataset.descriptions import com.enrpau.dualscreendex.parser.catalog.CatalogParser import com.enrpau.dualscreendex.parser.catalog.DescriptionRecord +import com.enrpau.dualscreendex.parser.analysis.RomAnalysisSession +import com.enrpau.dualscreendex.parser.detect.RomHeaderReader import com.enrpau.dualscreendex.parser.io.RomImage +import com.enrpau.dualscreendex.parser.model.CapabilityStatus +import com.enrpau.dualscreendex.parser.model.RomCapability import java.nio.file.Files import java.nio.file.Path import java.security.MessageDigest @@ -14,6 +18,77 @@ import org.junit.Test /** Real-ROM characterization for the ordinary Gen III typed-description cutover. */ class DescriptionLiveRomTest { + @Test fun celiaPublishesItsCompiledPokedexEntryDescriptions() { + val configured = System.getenv("DUALDEX_CELIA_ROM") + assumeTrue("set DUALDEX_CELIA_ROM to run this live-ROM regression", !configured.isNullOrBlank()) + val path = Path.of(requireNotNull(configured)) + assumeTrue("live ROM does not exist: $path", Files.isRegularFile(path)) + val rom = RomImage(Files.readAllBytes(path)) + assertEquals( + "81ac9b9d4e7bdd3bf06ed53954d784118a743372906c6c6fc62b3cbc19587148", + rom.sha256, + ) + + val session = RomAnalysisSession(rom, RomHeaderReader.read(rom)) + val direct = DescriptionCodec().decode( + session, + DescriptionTableLayout(0xCA6B70, 386, 36, listOf(16)), + ) as DescriptionTableOutcome.Decoded + assertEquals(384, direct.rows.count { it is DescriptionRowOutcome.Decoded }) + assertEquals(1, direct.rows.count { it is DescriptionRowOutcome.StructuralEmpty }) + assertEquals(1, direct.rows.count { it is DescriptionRowOutcome.Malformed }) + val reference = requireNotNull(session.gbaReferenceIndex?.target(0xCA6B70)) + assertEquals(8, reference.count) + val resolution = DescriptionResolver().resolve(session, 386) + assertTrue( + resolution.toString(), + resolution is com.enrpau.dualscreendex.parser.resolution.DatasetResolution.Partial, + ) + assertEquals( + DescriptionTableLayout(0xCA6B70, 386, 36, listOf(16)), + (resolution as com.enrpau.dualscreendex.parser.resolution.DatasetResolution.Partial).candidate.layout.table, + ) + + val parsed = CatalogParser.parse(rom) + val layout = requireNotNull(parsed.layout) + val catalogSpecies = requireNotNull(parsed.catalog).speciesById.values + val selected = requireNotNull(layout.tables.descriptions) + assertEquals(0xCA6B70, selected.offset) + assertEquals(386, selected.count) + assertEquals(36, selected.recordSize) + assertEquals(listOf(16), selected.pointerOffsets) + val capability = parsed.analysis.capabilities.single { + it.capability == RomCapability.POKEDEX_DESCRIPTIONS + } + assertEquals(CapabilityStatus.PARTIAL, capability.status) + assertEquals(382, capability.coveredRecords) + assertEquals(384, capability.expectedRecords) + assertEquals(2, capability.incompleteRecords) + + val typed = requireNotNull(layout.resolvedDatasets.descriptions).catalogDescriptions() + val navigableSpecies = catalogSpecies.filter { (it.dexNumber.value ?: 0) > 0 } + assertEquals(384, typed.size) + assertEquals("UNKNOWN", typed.getValue(0).category) + assertEquals("FIRST", typed.getValue(2).category) + assertEquals(setOf(154, 197), (0 until 386).filterNot(typed::containsKey).toSet()) + assertEquals(411, navigableSpecies.size) + assertEquals(411, navigableSpecies.maxOf { it.dexNumber.value ?: 0 }) + assertEquals( + setOf( + 40, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, + 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 408, + ), + navigableSpecies + .filter { it.description.value == null } + .mapTo(linkedSetOf()) { it.id }, + ) + assertEquals(383, navigableSpecies.count { it.description.value != null }) + assertEquals( + "d62c951fc4ddbf35f3a251ea9b26c4e1664c1ff2b957601db800d73ecbfedad7", + descriptionSha256(typed), + ) + } + @Test fun aGrandDayOutThirtySixByteRowsHaveExactTypedPayloadParity() = assertCodecParity( "DUALDEX_A_GRAND_DAY_OUT_ROM", "2005275fc54ae63f3d1bc50c49980e87dcd9ecae5e4733d322bb2a2c99270916", diff --git a/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionResolverTest.kt b/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionResolverTest.kt index 45a99883..2220457d 100644 --- a/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionResolverTest.kt +++ b/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/dataset/descriptions/DescriptionResolverTest.kt @@ -160,11 +160,22 @@ class DescriptionResolverTest { decodeAttempts++ DescriptionTableOutcome.Rejected(layout, "fixture invalid root") } - val references = (0 until 128).associate { index -> 0x400 + index * 4 to 1 } + val bytes = ByteArray(0x8000) + val references = (0 until 128).associate { index -> 0x400 + index * 0x40 to 1 } + references.keys.forEachIndexed { index, root -> + putDescriptionTable( + bytes = bytes, + offset = root, + count = 1, + recordSize = 36, + pointerOffsets = listOf(16), + textOffset = 0x3000 + index * 0x20, + ) + } val result = DescriptionResolver(decoder).resolve( session = descriptionSession( - bytes = ByteArray(0x1000), + bytes = bytes, references = references, limits = ResolutionLimits(maxProbeRootsPerDataset = 3), ), diff --git a/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/parse/GbaPublishedHeaderResolverTest.kt b/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/parse/GbaPublishedHeaderResolverTest.kt index 9e9212a0..e5182eee 100644 --- a/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/parse/GbaPublishedHeaderResolverTest.kt +++ b/parser-core/src/test/kotlin/com/enrpau/dualscreendex/parser/parse/GbaPublishedHeaderResolverTest.kt @@ -6,6 +6,21 @@ import org.junit.Assert.assertNull import org.junit.Test class GbaPublishedHeaderResolverTest { + @Test + fun resolvesPublishedPokedexCountOnlyWithTheFixedHeaderPointerRoles() { + val bytes = ByteArray(0x30000) + writePointer(bytes, 0x128, 0x8000) + writePointer(bytes, 0x144, 0x9000) + writePointer(bytes, 0x148, 0xA000) + repeat(386) { id -> encodeGbaName(bytes, 0x9000 + id * 11, "MON") } + writeU32(bytes, 0x168, 386) + + assertEquals(386, GbaPublishedHeaderResolver.resolve(RomImage(bytes)).pokedexCount) + + bytes.fill(0, 0x144, 0x148) + assertNull(GbaPublishedHeaderResolver.resolve(RomImage(bytes)).pokedexCount) + } + @Test fun resolvesCompactGfHeaderPointerBlock() { val bytes = ByteArray(0x20000) @@ -115,4 +130,8 @@ class GbaPublishedHeaderResolverTest { val value = 0x08000000 + target repeat(4) { index -> bytes[offset + index] = (value ushr (index * 8)).toByte() } } + + private fun writeU32(bytes: ByteArray, offset: Int, value: Int) { + repeat(4) { index -> bytes[offset + index] = (value ushr (index * 8)).toByte() } + } } From d61d10ed83d397f4057f1a117c2939845cdd20d0 Mon Sep 17 00:00:00 2001 From: Darkaxt Date: Fri, 14 Aug 2026 19:47:34 +0300 Subject: [PATCH 2/3] Document Celia description compatibility --- README.md | 4 +- ...14-first50-celia-pokedex-descriptions.json | 64 +++++++++++++++++++ ...8-14-first50-celia-pokedex-descriptions.md | 52 +++++++++++++++ .../2026-08-14-first50-table-hole-closure.md | 4 ++ ...08-14-first50-table-hole-closure-design.md | 8 +++ release/RELEASE_NOTES_1.0.0.md | 7 ++ release/v1-ready.json | 3 + 7 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 docs/reports/2026-08-14-first50-celia-pokedex-descriptions.json create mode 100644 docs/reports/2026-08-14-first50-celia-pokedex-descriptions.md diff --git a/README.md b/README.md index b7cba27d..a72ff796 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ DualDex is a passive Pokédex companion for mainline-family Pokémon games runni The game remains on the primary display. DualDex detects the active GB, GBC, or GBA content, parses the user's ROM into a local SQLite Pokédex, and refreshes seen/caught/team/area knowledge from checksum-valid SaveRAM. Validated live layouts can supersede stale disk state for current location, party, and battle context through RetroArch's read-only Network Commands. It does this without OCR, screenshots, cheats, memory writes, or per-hack profiles. A separately isolated issue-report tool can export read-only evidence for unsupported layouts, but its dumps never feed the production Pokédex. > [!IMPORTANT] -> The pure-Kotlin ROM parser, materialized SQLite catalog, Gen I–III SaveRAM readers, validated live-WRAM paths, loopback web host, Thor-first UI, passive RetroArch activation, Docked/Overlay modes, and isolated read-only issue reports are implemented. Release candidate `v1.0.0-rc.28` adds broader first-50 move-table coverage and clearer loading/map behavior to RC27's complete evolution catalogs, consistent Organic identity artwork, normalized ROM-derived maps, semantic ARM7TDMI mechanics, and proven-wild Rarity behavior. Stable `v1.0.0` has not been released. +> The pure-Kotlin ROM parser, materialized SQLite catalog, Gen I–III SaveRAM readers, validated live-WRAM paths, loopback web host, Thor-first UI, passive RetroArch activation, Docked/Overlay modes, and isolated read-only issue reports are implemented. Release candidate `v1.0.0-rc.29` adds source-backed compiled Pokédex-description recovery to RC28's broader first-50 move-table coverage, complete evolution catalogs, consistent Organic identity artwork, normalized ROM-derived maps, semantic ARM7TDMI mechanics, and proven-wild Rarity behavior. Stable `v1.0.0` has not been released. ## Thor-first UI direction @@ -195,7 +195,7 @@ These denominators are deliberately different. A selected base catalog is not co Numeric ability mechanics are tracked separately from names and descriptions. The production resolver follows decoded calls and use-def relationships from parser-selected layouts into typed battle fields, predicates, arithmetic, and writeback. It never substitutes familiar series values, names, hashes, symbols, or fixed routine addresses for missing proof. -Read the player-facing [ROM Hacks Compatibility report](reports/dualdex-rom-hacks-compatibility.md), with its [machine-readable JSON](reports/dualdex-rom-hacks-compatibility.json), for the reviewed first 50 ROMs grouped by generation and engine family. The separate [Parser Compatibility report](reports/dualdex-parser-compatibility.md) and [schema-11 JSON evidence](reports/dualdex-parser-compatibility.json) retain the reviewed RC24 evidence contract; the independent [exact first-50 base release gate](docs/reports/2026-08-13-base-first50-release-gate.md) records RC25's 50/50 result without rewriting that historical report. Optional capability evidence is published independently in the [exact first-50 evolution gate](docs/reports/2026-08-14-first50-evolution-completeness.md), [world-map first-50 release gate](docs/reports/2026-08-13-map-first50-release-gate.md), and [ARM7TDMI first-50 survey](docs/reports/arm7-first50-compatibility-survey.md). The [full unique-ROM base audit](docs/reports/2026-08-13-base-full332-compatibility.md) keeps broader coverage visible without treating every optional feature as resolved. Reports contain structural evidence and hashes, but no decoded bulk tables, sprites, ROM bytes, saves, trainer data, or private paths. +Read the player-facing [ROM Hacks Compatibility report](reports/dualdex-rom-hacks-compatibility.md), with its [machine-readable JSON](reports/dualdex-rom-hacks-compatibility.json), for the reviewed first 50 ROMs grouped by generation and engine family. The separate [Parser Compatibility report](reports/dualdex-parser-compatibility.md) and [schema-11 JSON evidence](reports/dualdex-parser-compatibility.json) retain the reviewed RC24 evidence contract; the independent [exact first-50 base release gate](docs/reports/2026-08-13-base-first50-release-gate.md) records RC25's 50/50 result without rewriting that historical report. Optional capability evidence is published independently in the [exact first-50 evolution gate](docs/reports/2026-08-14-first50-evolution-completeness.md), [Celia Pokédex-description closure](docs/reports/2026-08-14-first50-celia-pokedex-descriptions.md), [world-map first-50 release gate](docs/reports/2026-08-13-map-first50-release-gate.md), and [ARM7TDMI first-50 survey](docs/reports/arm7-first50-compatibility-survey.md). The [full unique-ROM base audit](docs/reports/2026-08-13-base-full332-compatibility.md) keeps broader coverage visible without treating every optional feature as resolved. Reports contain structural evidence and hashes, but no decoded bulk tables, sprites, ROM bytes, saves, trainer data, or private paths. SaveRAM evidence is reported separately for [Generations I/II](docs/reports/gen1-gen2-saveram-compatibility.md) and [Generation III](docs/reports/gen3-saveram-compatibility.md). These reports contain no ROM/save bytes, trainer data, or private filesystem paths. diff --git a/docs/reports/2026-08-14-first50-celia-pokedex-descriptions.json b/docs/reports/2026-08-14-first50-celia-pokedex-descriptions.json new file mode 100644 index 00000000..71e8b51b --- /dev/null +++ b/docs/reports/2026-08-14-first50-celia-pokedex-descriptions.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 1, + "date": "2026-08-14", + "scope": "exact-first-50", + "change": "compiled Gen III Pokedex-entry descriptions", + "implementationCommit": "b5809db", + "numericCompatibility": { + "first50AverageBefore": 95.53, + "first50AverageAfter": 95.63, + "celiaBefore": 90.40, + "celiaAfter": 95.14, + "celiaResolvedFeaturesBefore": 19, + "celiaResolvedFeaturesAfter": 20, + "celiaExpectedFeatures": 21 + }, + "capabilities": { + "pokedexDescriptionsAvailableBefore": 35, + "pokedexDescriptionsAvailableAfter": 35, + "pokedexDescriptionsPartialBefore": 11, + "pokedexDescriptionsPartialAfter": 12, + "pokedexDescriptionsNotFoundBefore": 4, + "pokedexDescriptionsNotFoundAfter": 3, + "evolutionsAvailable": 50 + }, + "celia": { + "sha256": "81ac9b9d4e7bdd3bf06ed53954d784118a743372906c6c6fc62b3cbc19587148", + "family": "FIRERED_LEAFGREEN", + "tableRows": 386, + "recordSize": 36, + "descriptionPointerOffset": 16, + "decodedPhysicalRows": 384, + "semanticCoveredRows": 382, + "semanticExpectedRows": 384, + "semanticSha256": "d62c951fc4ddbf35f3a251ea9b26c4e1664c1ff2b957601db800d73ecbfedad7", + "referenceErrors": 0, + "sqliteBytes": 1527808, + "catalogSections": 12, + "sourceOracleCommit": "8b31f2472810f75571d122159d164467e149d4a8" + }, + "exact50": { + "rows": 50, + "selected": 50, + "ambiguous": 0, + "noFamilyMatch": 0, + "errors": 0, + "exactShaIdentities": 50, + "routingDeltas": 0, + "first33RoutingOrReferenceDeltas": 0, + "referenceErrorRows": 0, + "persistedAndReopened": 50, + "sqliteQuickCheckErrors": 0, + "sqliteForeignKeyErrors": 0, + "catalogSectionCounts": [12] + }, + "verification": { + "nonThumbParserTests": 996, + "nonThumbParserFailures": 0, + "nonThumbParserErrors": 0, + "nonThumbParserSkipped": 106, + "focusedDescriptionGate": "BUILD_SUCCESSFUL", + "rawJsonSha256": "ac10886a7bc3afb2f48c49202eb9bfb3011db0bb045cc14f43ca0377a7813d02", + "rawMarkdownSha256": "639a99c9679be39eb906d045cad3894b0e95d399892387974a8f214ee85e9592" + } +} diff --git a/docs/reports/2026-08-14-first50-celia-pokedex-descriptions.md b/docs/reports/2026-08-14-first50-celia-pokedex-descriptions.md new file mode 100644 index 00000000..e15f3dcb --- /dev/null +++ b/docs/reports/2026-08-14-first50-celia-pokedex-descriptions.md @@ -0,0 +1,52 @@ +# First-50 Celia Pokédex-description closure — 2026-08-14 + +## Outcome + +Celia's Stupid Romhack now resolves its compiled Gen III Pokédex-entry table. + +- First-50 numeric compatibility average: **95.53% → 95.63%**. +- Celia numeric compatibility: **90.40% → 95.14%** (**19/21 → 20/21** applicable features). +- First-50 `POKEDEX_DESCRIPTIONS`: **35 available / 11 partial / 4 not found → 35 available / 12 partial / 3 not found**. +- Celia publishes **382 decoded descriptions across 384 navigable Pokédex species**. The two unsupported active rows remain unavailable rather than receiving external or fabricated text. +- Evolutions remain **50/50 available**. THUMB ability mechanics are unchanged and intentionally deferred. + +## Structural authority + +Production selection uses ROM-derived structure only: + +- the source-defined published Gen III header supplies a bounded Pokédex count only when its fixed species-name, move-name, and sprite pointer roles are valid; +- the published count must be within the independently decoded species-name domain; +- compiled ROM references nominate description-table candidates; +- the typed codec validates all 386 records under the selected 36-byte ABI and description pointer at `+16`; +- the typed candidate must agree with the existing structural description validator; +- the independently compiled species-to-Dex mapping defines the navigable semantic domain; +- partial rows and out-of-domain internal slots stay unavailable. + +No ROM name, SHA-256, source symbol, or absolute table offset participates in production selection. Exact identity and addresses exist only in the live regression. + +The comparative source oracle is Celia's Stupid Repository commit `8b31f2472810f75571d122159d164467e149d4a8`. Its `PokedexEntry` layout and `NATIONAL_DEX_COUNT` agree with the independently decoded ROM table. The exact live ROM SHA-256 is `81ac9b9d4e7bdd3bf06ed53954d784118a743372906c6c6fc62b3cbc19587148`. + +## Fail-closed behavior + +- Retail and previously supported hack layouts retain their existing typed selection path. +- A published count without the required fixed header pointer roles is ignored. +- A count outside the independently decoded species-name domain is ignored. +- Typed discovery is used only when the existing structural description path fails. +- Typed and legacy structural selection must agree on root, count, and record width. +- The two non-decodable active rows remain missing; a partial table cannot erase otherwise valid species identities. + +## Verification + +- Implementation commit: `b5809db` (`Resolve compiled Pokedex descriptions`). +- Full non-THUMB parser regression: **996 tests, 0 failures, 0 errors**; 106 opt-in controls skipped. +- Focused live description, resolver, published-header, and one-pass architecture gate: **BUILD SUCCESSFUL**. +- Exact first-50: **50/50 SELECTED**, 0 ambiguous, 0 no-family, 0 errors. +- Exact identity/order: **50/50**; routing deltas: **0/50**; first-33 routing/reference deltas: **0/33**. +- Reference errors: **0/50**. +- Persistence: **50/50** catalogs written and reopened; each contains 12 sections. +- SQLite validation: **50/50** `quick_check=ok`; **0** foreign-key findings. +- Celia SQLite: **1,527,808 bytes**, 12 sections, reopened successfully. +- Raw JSON SHA-256: `ac10886a7bc3afb2f48c49202eb9bfb3011db0bb045cc14f43ca0377a7813d02`. +- Raw Markdown SHA-256: `639a99c9679be39eb906d045cad3894b0e95d399892387974a8f214ee85e9592`. + +Only one exact-50 corpus pass was run after the focused implementation and controls. diff --git a/docs/superpowers/plans/2026-08-14-first50-table-hole-closure.md b/docs/superpowers/plans/2026-08-14-first50-table-hole-closure.md index 7bd4dfaa..8d2b59ab 100644 --- a/docs/superpowers/plans/2026-08-14-first50-table-hole-closure.md +++ b/docs/superpowers/plans/2026-08-14-first50-table-hole-closure.md @@ -11,3 +11,7 @@ 9. Add a real-ROM RED requiring complete move names/details plus malformed and duplicate-root fail-closed mutations. 10. Implement compiled-nominated, dense-domain unified move selection and the typed 48-byte decoder; reuse the shared parser session and existing catalog materializers. 11. Verify Dreamstone through CatalogParser, SQLite, and reference closure; run focused controls and one exact first-50 regression matrix only. +12. Freeze Celia's source-defined 386-row `PokedexEntry` layout and exact compiled-reference evidence without building the source project. +13. Add a real-ROM regression for the typed 36-byte rows, description pointer at `+16`, partial physical rows, and semantic Pokédex coverage. +14. Integrate the bounded published Pokédex count into the shared description phase, requiring typed and legacy structural agreement and preserving independently compiled species identities. +15. Verify Celia through CatalogParser and SQLite, run the full non-THUMB parser regression, then one fresh exact-first-50 matrix and publish the numeric before/after evidence. diff --git a/docs/superpowers/specs/2026-08-14-first50-table-hole-closure-design.md b/docs/superpowers/specs/2026-08-14-first50-table-hole-closure-design.md index d8342c77..7904b620 100644 --- a/docs/superpowers/specs/2026-08-14-first50-table-hole-closure-design.md +++ b/docs/superpowers/specs/2026-08-14-first50-table-hole-closure-design.md @@ -41,3 +41,11 @@ Dreamstone source defines one `gMovesInfo` array of 48-byte `MoveInfo` records. Production selection must start from compiled-reference targets and the independently decoded positive move-ID domain already present in complete learnsets. It may admit the source-defined 48-byte ABI only when exactly one referenced root provides complete pointer names and typed detail rows for every ID from zero through the maximum referenced move ID. Table-wide content validates a compiled-nominated root; it cannot nominate a raw ROM offset. Invalid pointers, malformed packed fields, incomplete dense coverage, exhausted evidence, and multiple complete roots fail closed. Production must not select by ROM name, SHA, source symbol, absolute address, or source revision. The normal catalog path must publish `MOVE_CATALOG` and `MOVE_DETAILS` as `AVAILABLE`, preserve the ordinary move IDs and decoded names/details, keep unsupported special-only move records outside the ordinary domain, preserve zero reference errors, and survive SQLite write/reopen. Ability mechanics remain independently gated and must not be promoted merely because the move ABI becomes available. + +## Later slice: compiled Pokédex-entry descriptions + +After Celia's widened move details were resolved, its next non-THUMB gap was the Pokédex-description table. The comparative source defines 386 `PokedexEntry` rows with a 36-byte record and one description pointer at `+16`; the exact ROM independently contains that table with eight compiled references. + +Production may use the published Gen III Pokédex count only when the fixed species-name, move-name, and sprite header roles are valid and the count lies inside the independently decoded species-name domain. Typed discovery then remains compiled-reference-nominated and must agree with the legacy structural validator. Partial records stay unavailable, and a partial description table cannot invalidate an independently compiled species-to-Dex map. + +The output gate is truthful semantic coverage, not an all-or-nothing label: Celia must report 382 decoded descriptions across 384 navigable Pokédex species, preserve two unavailable active rows, retain zero reference errors, survive SQLite reopen, and leave the other 49 exact-first-50 results unchanged. THUMB ability mechanics are out of scope for this slice. diff --git a/release/RELEASE_NOTES_1.0.0.md b/release/RELEASE_NOTES_1.0.0.md index 405f850a..f86c496e 100644 --- a/release/RELEASE_NOTES_1.0.0.md +++ b/release/RELEASE_NOTES_1.0.0.md @@ -2,6 +2,13 @@ DualDex is a passive ROM, SaveRAM, and validated live-WRAM Pokédex companion for mainline-family Pokémon games from Game Boy through Game Boy Advance. +## RC29: broader ROM-native Pokédex entries + +- Resolves Celia's compiled 386-row Gen III Pokédex-entry table structurally, publishing 382 ROM-native descriptions across 384 navigable Pokédex species. The two unsupported active rows remain unavailable rather than receiving external or fabricated text. +- Raises Celia's measured compatibility from **90.40% to 95.14%** and the exact-first-50 average from **95.53% to 95.63%**. First-50 Pokédex-description coverage changes from **35 available / 11 partial / 4 not found** to **35 available / 12 partial / 3 not found**. +- Preserves the exact base gate at **50/50 selected**, zero routing or first-33 reference deltas, zero cross-reference errors, and 50/50 SQLite write/reopen/quick-check success. +- Leaves THUMB ability mechanics unchanged and deferred. Existing evolution coverage remains **50/50 available**. + ## RC28: broader move data and clearer map state - Resolves Celia's widened 16-byte move-detail table structurally, publishing all 1,188 validated move rows without ROM-name, hash, or fixed-offset selection. First-50 `MOVE_DETAILS` availability increases from **47/50 to 48/50**, and Celia's measured compatibility rises from **85.64% to 90.40%**. diff --git a/release/v1-ready.json b/release/v1-ready.json index e6792ffa..9aab4f4d 100644 --- a/release/v1-ready.json +++ b/release/v1-ready.json @@ -10,6 +10,9 @@ "mapFirst50Available": 26, "evolutionFirst50Complete": 50, "moveDetailsFirst50Available": 48, + "pokedexDescriptionsFirst50Available": 35, + "pokedexDescriptionsFirst50Partial": 12, + "pokedexDescriptionsFirst50NotFound": 3, "arm7Applicable": 46, "arm7ProductionComplete": 38, "productionCertificateSha256": "C5A02CECB47CDA41B618817EA684CBB6CCFDCC17A3E7D8243448175C8E3B2FBA" From d37b97d6146ac6d1c367183cdf6fbd9415705d05 Mon Sep 17 00:00:00 2001 From: Darkaxt Date: Fri, 14 Aug 2026 20:41:20 +0300 Subject: [PATCH 3/3] Harden RC29 build dependencies --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 4 +- README.md | 2 +- build.gradle.kts | 95 +++++++++++++++++++++++- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 4 +- release/RELEASE_NOTES_1.0.0.md | 2 + release/v1-ready.json | 2 + 8 files changed, 104 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0669c4c1..7a12c062 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: run: pwsh -File tools/android/Test-DualDexAndroidTools.ps1 - name: Run Kotlin tests - run: .\gradlew.bat :parser-core:test :parser-cli:test :companion-core:test :companion-simulator:test :companion-server:test --stacktrace + run: .\gradlew.bat verifySecureBuildDependencies :parser-core:test :parser-cli:test :companion-core:test :companion-simulator:test :companion-server:test --stacktrace - name: Test web UI working-directory: companion-web diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a739568..983f653c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,7 +108,7 @@ jobs: docs/reports/2026-08-13-map-first50-release-gate-raw.json >/dev/null jq -e '.summary.identities == 50 and .summary.completeTables == 50 and .summary.malformedRows == 0 and .summary.deterministicHashes == 50 and .summary.sqliteReopenExact == 50 and .summary.originalCompleteTablesPreserved == .summary.originalCompleteTables and (.rows | length) == 50' \ docs/reports/2026-08-14-first50-evolution-completeness-raw.json >/dev/null - jq -e '.baseFirst50Selected == 50 and .mapFirst50Available == 26 and .evolutionFirst50Complete == 50 and .arm7Applicable == 46 and .arm7ProductionComplete == 38 and (has("debugApkSha256") | not)' \ + jq -e '.baseFirst50Selected == 50 and .mapFirst50Available == 26 and .evolutionFirst50Complete == 50 and .secureBuildDependencyGate == true and .securityAdvisoriesCovered == 48 and .arm7Applicable == 46 and .arm7ProductionComplete == 38 and (has("debugApkSha256") | not)' \ release/v1-ready.json >/dev/null if grep -Eiq '[A-Z]:\\|dualdex-expanded-corpus|ExtractedPath' \ reports/dualdex-parser-compatibility.json \ @@ -173,7 +173,7 @@ jobs: - name: Test all modules, lint, and build unsigned release APK shell: bash run: >- - bash ./gradlew test :app:lintDebug :app:assembleRelease + bash ./gradlew verifySecureBuildDependencies test :app:lintDebug :app:assembleRelease -PdualdexVersionName=${{ steps.metadata.outputs.version_name }} -PdualdexVersionCode=${{ steps.metadata.outputs.version_code }} --stacktrace diff --git a/README.md b/README.md index a72ff796..14dfef0f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ DualDex is a passive Pokédex companion for mainline-family Pokémon games runni The game remains on the primary display. DualDex detects the active GB, GBC, or GBA content, parses the user's ROM into a local SQLite Pokédex, and refreshes seen/caught/team/area knowledge from checksum-valid SaveRAM. Validated live layouts can supersede stale disk state for current location, party, and battle context through RetroArch's read-only Network Commands. It does this without OCR, screenshots, cheats, memory writes, or per-hack profiles. A separately isolated issue-report tool can export read-only evidence for unsupported layouts, but its dumps never feed the production Pokédex. > [!IMPORTANT] -> The pure-Kotlin ROM parser, materialized SQLite catalog, Gen I–III SaveRAM readers, validated live-WRAM paths, loopback web host, Thor-first UI, passive RetroArch activation, Docked/Overlay modes, and isolated read-only issue reports are implemented. Release candidate `v1.0.0-rc.29` adds source-backed compiled Pokédex-description recovery to RC28's broader first-50 move-table coverage, complete evolution catalogs, consistent Organic identity artwork, normalized ROM-derived maps, semantic ARM7TDMI mechanics, and proven-wild Rarity behavior. Stable `v1.0.0` has not been released. +> The pure-Kotlin ROM parser, materialized SQLite catalog, Gen I–III SaveRAM readers, validated live-WRAM paths, loopback web host, Thor-first UI, passive RetroArch activation, Docked/Overlay modes, and isolated read-only issue reports are implemented. Release candidate `v1.0.0-rc.29` adds source-backed compiled Pokédex-description recovery and a Dependabot-audited build-toolchain gate to RC28's broader first-50 move-table coverage, complete evolution catalogs, consistent Organic identity artwork, normalized ROM-derived maps, semantic ARM7TDMI mechanics, and proven-wild Rarity behavior. Stable `v1.0.0` has not been released. ## Thor-first UI direction diff --git a/build.gradle.kts b/build.gradle.kts index cea22a5a..d8362c80 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,97 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + configurations.configureEach { + resolutionStrategy.eachDependency { + val secureVersion = when (requested.group to requested.name) { + "org.bouncycastle" to "bcpkix-jdk18on", + "org.bouncycastle" to "bcprov-jdk18on", + "org.bouncycastle" to "bcutil-jdk18on" -> "1.84" + "org.bitbucket.b_c" to "jose4j" -> "0.9.6" + "org.jdom" to "jdom2" -> "2.0.6.1" + "org.apache.commons" to "commons-lang3" -> "3.18.0" + "org.apache.httpcomponents" to "httpclient" -> "4.5.14" + else -> null + } + if (secureVersion != null) { + useVersion(secureVersion) + because("Keep the Android build toolchain above published security floors") + } + } + } +} + plugins { alias(libs.plugins.android.application) apply false - id("org.jetbrains.kotlin.android") version "2.3.10" apply false - id("org.jetbrains.kotlin.jvm") version "2.3.10" apply false + id("org.jetbrains.kotlin.android") version "2.4.20-Beta2" apply false + id("org.jetbrains.kotlin.jvm") version "2.4.20-Beta2" apply false +} + +allprojects { + configurations.configureEach { + resolutionStrategy.eachDependency { + val secureVersion = when { + requested.group == "io.netty" -> "4.1.136.Final" + requested.group == "org.bouncycastle" && requested.name in setOf( + "bcpkix-jdk18on", + "bcprov-jdk18on", + "bcutil-jdk18on", + ) -> "1.84" + requested.group == "org.bitbucket.b_c" && requested.name == "jose4j" -> "0.9.6" + requested.group == "org.jdom" && requested.name == "jdom2" -> "2.0.6.1" + requested.group == "org.apache.commons" && requested.name == "commons-lang3" -> "3.18.0" + requested.group == "org.apache.httpcomponents" && requested.name == "httpclient" -> "4.5.14" + else -> null + } + if (secureVersion != null) { + useVersion(secureVersion) + because("Keep build and test tooling above published security floors") + } + } + } +} + +tasks.register("verifySecureBuildDependencies") { + group = "verification" + description = "Verifies the resolved build and Android test toolchains against the RC29 security pins." + + doLast { + val expectedVersions = mapOf( + "org.jetbrains.kotlin:kotlin-gradle-plugin" to setOf("2.4.20-Beta2"), + "org.bouncycastle:bcpkix-jdk18on" to setOf("1.84"), + "org.bouncycastle:bcprov-jdk18on" to setOf("1.84"), + "org.bitbucket.b_c:jose4j" to setOf("0.9.6"), + "org.jdom:jdom2" to setOf("2.0.6.1"), + "org.apache.commons:commons-lang3" to setOf("3.18.0"), + "org.apache.httpcomponents:httpclient" to setOf("4.5.14"), + "com.google.protobuf:protobuf-java" to setOf("3.25.5", "4.28.3"), + "com.google.protobuf:protobuf-kotlin" to setOf("4.28.3"), + ) + val resolved = mutableSetOf>() + val configurationsToCheck = buildList { + addAll(rootProject.buildscript.configurations.filter { it.isCanBeResolved }) + rootProject.allprojects.forEach { project -> + addAll(project.configurations.filter { it.isCanBeResolved }) + } + } + + configurationsToCheck.forEach { configuration -> + configuration.incoming.resolutionResult.allComponents.forEach { component -> + component.moduleVersion?.let { module -> + resolved += "${module.group}:${module.name}" to module.version + } + } + } + + val violations = resolved.mapNotNull { (coordinate, version) -> + val expected = when { + coordinate.startsWith("io.netty:") -> setOf("4.1.136.Final") + else -> expectedVersions[coordinate] + } ?: return@mapNotNull null + if (version in expected) null else "$coordinate:$version (expected ${expected.joinToString(" or ")})" + }.sorted() + + check(violations.isEmpty()) { + "Unsafe build-tool dependency versions resolved:\n${violations.joinToString("\n")}" + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b63abe18..40e07202 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.0.0" +agp = "9.2.1" coreKtx = "1.10.1" junit = "4.13.2" junitVersion = "1.1.5" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c53b1a7e..51671e0b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ #Sun Feb 01 23:35:39 GMT 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/release/RELEASE_NOTES_1.0.0.md b/release/RELEASE_NOTES_1.0.0.md index f86c496e..138e993e 100644 --- a/release/RELEASE_NOTES_1.0.0.md +++ b/release/RELEASE_NOTES_1.0.0.md @@ -8,6 +8,8 @@ DualDex is a passive ROM, SaveRAM, and validated live-WRAM Pokédex companion fo - Raises Celia's measured compatibility from **90.40% to 95.14%** and the exact-first-50 average from **95.53% to 95.63%**. First-50 Pokédex-description coverage changes from **35 available / 11 partial / 4 not found** to **35 available / 12 partial / 3 not found**. - Preserves the exact base gate at **50/50 selected**, zero routing or first-33 reference deltas, zero cross-reference errors, and 50/50 SQLite write/reopen/quick-check success. - Leaves THUMB ability mechanics unchanged and deferred. Existing evolution coverage remains **50/50 available**. +- Upgrades the build toolchain to Gradle 9.4.1, Android Gradle Plugin 9.2.1, and Kotlin 2.4.20-Beta2; pins the audited Netty, Bouncy Castle, jose4j, JDOM, Commons Lang, HttpClient, and Protobuf dependency paths above their published security floors. +- Adds a resolved-configuration security gate to both CI and protected release signing. It covers all 48 Dependabot advisories open during the RC29 audit and verifies the Android lint/test toolchain without adding those build-only libraries to the APK runtime. ## RC28: broader move data and clearer map state diff --git a/release/v1-ready.json b/release/v1-ready.json index 9aab4f4d..08596237 100644 --- a/release/v1-ready.json +++ b/release/v1-ready.json @@ -13,6 +13,8 @@ "pokedexDescriptionsFirst50Available": 35, "pokedexDescriptionsFirst50Partial": 12, "pokedexDescriptionsFirst50NotFound": 3, + "secureBuildDependencyGate": true, + "securityAdvisoriesCovered": 48, "arm7Applicable": 46, "arm7ProductionComplete": 38, "productionCertificateSha256": "C5A02CECB47CDA41B618817EA684CBB6CCFDCC17A3E7D8243448175C8E3B2FBA"