From bc48391db9a8d0d6bd2c9ece096214e4498a584e Mon Sep 17 00:00:00 2001 From: Randy Olson Date: Tue, 29 Sep 2026 09:44:03 -0700 Subject: [PATCH] Prepare 0.5.2 with stage lookup and club IDs --- CHANGELOG.md | 10 ++++++++++ docs/guides/squad.md | 2 ++ pyproject.toml | 2 +- src/fmsave/_layouts/fm26_26_3_2.py | 12 ++++++------ src/fmsave/models/clubs.py | 8 +++++++- src/fmsave/readers/clubs.py | 4 ++++ tests/__snapshots__/test_layouts.ambr | 4 ++-- tests/__snapshots__/test_output_schema.ambr | 1 + tests/test_clubs.py | 21 +++++++++++++++++++++ tests/test_stages.py | 13 ++++++++++++- uv.lock | 2 +- 11 files changed, 67 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5dbb07..42e2b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.2] - 2026-09-29 + +### Added + +- `Club.unique_id` helps match clubs across separate careers. `Club.uid` still joins records within a save. + +### Fixed + +- `stages()` and its dependent readers can find stage tables further back in continued careers. + ## [0.5.1] - 2026-09-28 ### Added diff --git a/docs/guides/squad.md b/docs/guides/squad.md index d3a13e3..bfebc18 100644 --- a/docs/guides/squad.md +++ b/docs/guides/squad.md @@ -118,5 +118,7 @@ with fmsave.open("career.fm") as career_save: injuries = career_save.injuries().where(club_uid=club_uid) ``` +Use `Club.uid` within a save. To compare separate careers, use `Club.unique_id` when available. + [What a save holds](what-a-save-holds.md) lists all twenty-seven, and is honest about what none of them can give you. diff --git a/pyproject.toml b/pyproject.toml index 4c04b7c..75d7232 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fmsave" -version = "0.5.1" +version = "0.5.2" description = "Read Football Manager 26 save files into Python records, DataFrames, CSV and JSON." readme = "README.md" requires-python = ">=3.12" diff --git a/src/fmsave/_layouts/fm26_26_3_2.py b/src/fmsave/_layouts/fm26_26_3_2.py index 0d2290d..b6b1909 100644 --- a/src/fmsave/_layouts/fm26_26_3_2.py +++ b/src/fmsave/_layouts/fm26_26_3_2.py @@ -843,9 +843,9 @@ # A stage row is 33 bytes. The table starts 0.65 to 0.94 MB from the end of `game_db` on the # corpus saves, so the last 2 MB is searched first; one community career started on an older -# build has 4.1 MB after its table (4.40 MB from the end to its start), so the window may double -# to 16 MB before the table counts as missing. 200 rows is far longer than any run of look-alike -# bytes seen before it. +# build has 4.1 MB after its table. Another continued career has 20.7 MB after the table, so +# the window may double to 32 MiB before the table counts as missing. 200 rows is far longer +# than any run of look-alike bytes seen before it. STAGE_TABLE = StageTableLayout( row_bytes=33, previous_stage_id_offset=0, @@ -862,7 +862,7 @@ # competitions a save holds; this limit rejects it without touching a real competition. competition_id_limit=1_000_000, search_bytes=2_000_000, - max_search_bytes=16_000_000, + max_search_bytes=32 * 1024 * 1024, chain_rows=200, resynchronisation_bytes=4_096, ) @@ -1103,8 +1103,8 @@ # the rest, so this is a shape check rather than a sentinel. stage_trailing_sentinel=(0.95, None), # As far back as the stage table search reaches (see STAGE_TABLE): 0.63 to 0.92 MiB on the - # corpus saves and 3.94 MiB on a career started on an older build. - stage_table_tail_bytes=(None, 16 * 1024 * 1024), + # corpus saves, 3.94 MiB on one older-build career, and 20.7 MB on another. + stage_table_tail_bytes=(None, 32 * 1024 * 1024), competitions_minimum=(50, None), # The id-pair records name 91.47% to 91.52% of the stage table's competitions across two # careers and a live save, and no save leaves a single competition in conflict. The share diff --git a/src/fmsave/models/clubs.py b/src/fmsave/models/clubs.py index 98db29a..34ef431 100644 --- a/src/fmsave/models/clubs.py +++ b/src/fmsave/models/clubs.py @@ -37,7 +37,9 @@ class Club: Club names are not unique: look clubs up by uid. Attributes: - uid: The club's id in the game database (unconfirmed). + uid: The id other records in this save use to refer to this club. It can differ + between separate careers, so use unique_id to match clubs across saves + (unconfirmed). name: Full club name (unconfirmed). short_name: Short club name (unconfirmed). nation_id: Id of the nation whose league the club plays in (unconfirmed). @@ -57,6 +59,8 @@ class Club: value. last_league_position: League position at the end of the last completed season, or None when the save holds none. It is not the position in the current table. + unique_id: The club's Unique ID in the game database, for matching across separate + careers. None when it cannot be read (unconfirmed). """ uid: int @@ -70,6 +74,7 @@ class Club: parent_club_name: str | None reputation: int | None last_league_position: int | None + unique_id: int | None = None register_field_statuses(Team, unconfirmed=("team_id", "slot", "club_uid", "is_affiliate")) @@ -78,6 +83,7 @@ class Club: verified=("reputation", "last_league_position"), unconfirmed=( "uid", + "unique_id", "name", "short_name", "nation_id", diff --git a/src/fmsave/readers/clubs.py b/src/fmsave/readers/clubs.py index c47b4cd..4e7bc51 100644 --- a/src/fmsave/readers/clubs.py +++ b/src/fmsave/readers/clubs.py @@ -194,6 +194,9 @@ def read_club_index(game_db: bytes, layouts: ClubLayouts, file_name: str) -> Clu for position, (reputation, last_league_position) in zip(index_order, statuses, strict=True): record = records[position] parent_club_uid = affiliates.parent_by_club_uid.get(record.uid) + # As with people, the next object's header closes this club's object and carries + # its database Unique ID. The final accepted club has no known closing header. + unique_id = records[position + 1].uid if position + 1 < len(records) else None club = Club( uid=record.uid, name=record.name, @@ -206,6 +209,7 @@ def read_club_index(game_db: bytes, layouts: ClubLayouts, file_name: str) -> Clu parent_club_name=None if parent_club_uid is None else name_by_uid.get(parent_club_uid), reputation=reputation, last_league_position=last_league_position, + unique_id=unique_id, ) clubs.append(club) uid_by_club_index[record.club_index] = record.uid diff --git a/tests/__snapshots__/test_layouts.ambr b/tests/__snapshots__/test_layouts.ambr index 8ba27ac..1a84b6d 100644 --- a/tests/__snapshots__/test_layouts.ambr +++ b/tests/__snapshots__/test_layouts.ambr @@ -592,7 +592,7 @@ 'competition_id_limit': 1000000, 'competition_id_offset': 13, 'group_id_offset': 17, - 'max_search_bytes': 16000000, + 'max_search_bytes': 33554432, 'previous_stage_id_offset': 0, 'resynchronisation_bytes': 4096, 'round_offset': 21, @@ -2035,7 +2035,7 @@ ), 'stage_table_tail_bytes': tuple( None, - 16777216, + 33554432, ), 'stage_trailing_sentinel': tuple( 0.95, diff --git a/tests/__snapshots__/test_output_schema.ambr b/tests/__snapshots__/test_output_schema.ambr index 8339f91..43b1648 100644 --- a/tests/__snapshots__/test_output_schema.ambr +++ b/tests/__snapshots__/test_output_schema.ambr @@ -20,6 +20,7 @@ 'parent_club_name', 'reputation', 'last_league_position', + 'unique_id', ]), 'ClubFacilities': list([ 'club_uid', diff --git a/tests/test_clubs.py b/tests/test_clubs.py index 8fd5519..4293b2f 100644 --- a/tests/test_clubs.py +++ b/tests/test_clubs.py @@ -164,9 +164,29 @@ def test_reads_every_club_with_its_fields() -> None: parent_club_name=None, reputation=6500, last_league_position=2, + unique_id=5002, ) +def test_club_unique_id_comes_from_the_next_header_not_its_own_uid() -> None: + original = read_index(example_game_db(status_records=[])).clubs + changed = read_index( + example_game_db( + club_records=[ + replace(NORTHBRIDGE, uid=6001).record_bytes(), + SOUTHPORT.record_bytes(), + ATHLETIC.record_bytes(), + ], + status_records=[], + ) + ).clubs + + assert original[0].uid == 5001 + assert changed[0].uid == 6001 + assert original[0].unique_id == changed[0].unique_id == 5002 + assert changed[-1].unique_id is None + + def test_cross_border_stub_club_has_no_reputation() -> None: southport = read_index(example_game_db()).club_by_uid[5002] assert southport.name == "Southport Example" @@ -794,6 +814,7 @@ def test_clubs_export_with_nested_teams() -> None: {"team_id": 70002, "slot": 1, "club_uid": 5001, "is_affiliate": False}, ] assert first_row["reputation"] == 6500 + assert first_row["unique_id"] == 5002 def test_registered_club_layouts() -> None: diff --git a/tests/test_stages.py b/tests/test_stages.py index 00e757d..81b91a3 100644 --- a/tests/test_stages.py +++ b/tests/test_stages.py @@ -412,8 +412,9 @@ def test_healthy_stage_stats_pass_every_gate_and_a_small_section_applies_none() pytest.param({"gaps": 9}, ["stage_walk_gaps"], id="gaps-above"), pytest.param({"with_competition": 6_400}, ["stage_rows_with_competition"], id="few-joins"), pytest.param({"bytes_after_table": 4_132_399}, [], id="older-build-career-tail"), + pytest.param({"bytes_after_table": 20_693_727}, [], id="continued-career-tail"), pytest.param( - {"bytes_after_table": 17_000_000}, ["stage_table_tail_bytes"], id="table-too-early" + {"bytes_after_table": 34_000_000}, ["stage_table_tail_bytes"], id="table-too-early" ), pytest.param({"rows": 999}, ["stage_rows_minimum"], id="too-few-rows"), pytest.param({"ascending_steps": 7_000}, ["stage_ids_ascending"], id="ids-not-ascending"), @@ -594,6 +595,16 @@ def test_a_table_further_back_than_the_first_window_is_found_by_widening_it() -> assert stage_index.stats.bytes_after_table == layout.search_bytes * 2 +def test_a_table_with_twenty_megabytes_after_it_is_found() -> None: + layout = find_stage_layout(GAME_DB_SCHEMA, "") + game_db = stage_table_bytes(career_stage_rows(), trailing_bytes=20_693_727) + + stage_index = read_stage_index(game_db, layout, FILE_NAME) + + assert len(stage_index.stages) == STAGE_ROW_COUNT + assert stage_index.stats.bytes_after_table == 20_693_727 + + def test_a_table_past_the_widest_window_is_not_found() -> None: layout = small_search_layout() game_db = stage_table_bytes(career_stage_rows(), trailing_bytes=layout.max_search_bytes) diff --git a/uv.lock b/uv.lock index a1a0b4e..07b6d3a 100644 --- a/uv.lock +++ b/uv.lock @@ -359,7 +359,7 @@ wheels = [ [[package]] name = "fmsave" -version = "0.5.1" +version = "0.5.2" source = { editable = "." } dependencies = [ { name = "backports-zstd", marker = "python_full_version < '3.14'" },