Correct save train and region collection probes
This commit is contained in:
parent
fa25433216
commit
86511f9670
5 changed files with 251 additions and 113 deletions
11
README.md
11
README.md
|
|
@ -149,11 +149,12 @@ electric-only periodic-company override rewrites the world route-preference byte
|
||||||
company preference, ending it restores the base world byte, and runtime service state now carries
|
company preference, ending it restores the base world byte, and runtime service state now carries
|
||||||
both the active and last applied override instead of treating the route-preference lane as a
|
both the active and last applied override instead of treating the route-preference lane as a
|
||||||
reader-only bridge.
|
reader-only bridge.
|
||||||
Save inspection now also exposes the tagged live region header
|
Save inspection now also separates the shared `0x5209/0x520a/0x520b` save family correctly: the
|
||||||
`0x5209/0x520a/0x520b` plus its live-entry directory rooted at metadata dword `16`, and the
|
smaller direct `0x1d5` collection is the live train family and now exposes a live-entry
|
||||||
tagged placed-structure header `0x36b1/0x36b2/0x36b3`, as first-class owner seams, so the
|
directory rooted at metadata dword `16`, while the actual region collection is the larger
|
||||||
remaining city-connection / linked-transit blocker is record-body reconstruction rather than
|
non-direct `Marker09` family. The tagged placed-structure header `0x36b1/0x36b2/0x36b3` is
|
||||||
missing save-side collection identity.
|
grounded alongside them, so the remaining city-connection / linked-transit blocker is
|
||||||
|
record-body reconstruction rather than missing save-side collection identity.
|
||||||
That same seam now also derives the current live coupon burden directly from owned bond slots, so
|
That same seam now also derives the current live coupon burden directly from owned bond slots, so
|
||||||
later finance service work can consume a runtime reader instead of recomputing from scattered raw
|
later finance service work can consume a runtime reader instead of recomputing from scattered raw
|
||||||
fields.
|
fields.
|
||||||
|
|
|
||||||
|
|
@ -1614,7 +1614,7 @@ pub struct SmpSaveTaggedCollectionHeaderProbe {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct SmpSaveRegionCollectionDirectoryEntryProbe {
|
pub struct SmpSaveTrainCollectionDirectoryEntryProbe {
|
||||||
pub live_entry_id: u32,
|
pub live_entry_id: u32,
|
||||||
pub payload_relative_offset: u32,
|
pub payload_relative_offset: u32,
|
||||||
pub payload_relative_offset_hex: String,
|
pub payload_relative_offset_hex: String,
|
||||||
|
|
@ -1626,7 +1626,7 @@ pub struct SmpSaveRegionCollectionDirectoryEntryProbe {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct SmpSaveRegionCollectionDirectoryProbe {
|
pub struct SmpSaveTrainCollectionDirectoryProbe {
|
||||||
pub profile_family: String,
|
pub profile_family: String,
|
||||||
pub source_kind: String,
|
pub source_kind: String,
|
||||||
pub semantic_family: String,
|
pub semantic_family: String,
|
||||||
|
|
@ -1641,7 +1641,7 @@ pub struct SmpSaveRegionCollectionDirectoryProbe {
|
||||||
pub chain_head_live_entry_id: Option<u32>,
|
pub chain_head_live_entry_id: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub chain_tail_live_entry_id: Option<u32>,
|
pub chain_tail_live_entry_id: Option<u32>,
|
||||||
pub entries: Vec<SmpSaveRegionCollectionDirectoryEntryProbe>,
|
pub entries: Vec<SmpSaveTrainCollectionDirectoryEntryProbe>,
|
||||||
pub evidence: Vec<String>,
|
pub evidence: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2579,9 +2579,11 @@ pub struct SmpSaveCompanyChairmanAnalysisReport {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub world_finance_neighborhood: Option<SmpSaveWorldFinanceNeighborhoodProbe>,
|
pub world_finance_neighborhood: Option<SmpSaveWorldFinanceNeighborhoodProbe>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub region_collection_header: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
pub train_collection_header: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub region_collection_directory: Option<SmpSaveRegionCollectionDirectoryProbe>,
|
pub train_collection_directory: Option<SmpSaveTrainCollectionDirectoryProbe>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub region_collection_header: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub placed_structure_collection_header: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
pub placed_structure_collection_header: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -2843,8 +2845,9 @@ pub struct SmpInspectionReport {
|
||||||
pub save_world_finance_neighborhood_probe: Option<SmpSaveWorldFinanceNeighborhoodProbe>,
|
pub save_world_finance_neighborhood_probe: Option<SmpSaveWorldFinanceNeighborhoodProbe>,
|
||||||
pub save_company_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
pub save_company_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
||||||
pub save_chairman_profile_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
pub save_chairman_profile_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
||||||
|
pub save_train_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
||||||
|
pub save_train_collection_directory_probe: Option<SmpSaveTrainCollectionDirectoryProbe>,
|
||||||
pub save_region_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
pub save_region_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
||||||
pub save_region_collection_directory_probe: Option<SmpSaveRegionCollectionDirectoryProbe>,
|
|
||||||
pub save_placed_structure_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
pub save_placed_structure_collection_header_probe: Option<SmpSaveTaggedCollectionHeaderProbe>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub save_company_roster_probe: Option<SmpLoadedCompanyRoster>,
|
pub save_company_roster_probe: Option<SmpLoadedCompanyRoster>,
|
||||||
|
|
@ -3109,9 +3112,9 @@ pub fn load_save_slice_from_report(
|
||||||
probe.close_tag_offset
|
probe.close_tag_offset
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(probe) = &report.save_region_collection_header_probe {
|
if let Some(probe) = &report.save_train_collection_header_probe {
|
||||||
notes.push(format!(
|
notes.push(format!(
|
||||||
"Raw save tagged region header reports live_record_count={} and live_id_bound={} with direct_record_stride=0x{:x} at file offsets 0x{:x}/0x{:x}/0x{:x}.",
|
"Raw save tagged train header reports live_record_count={} and live_id_bound={} with direct_record_stride=0x{:x} at file offsets 0x{:x}/0x{:x}/0x{:x}.",
|
||||||
probe.live_record_count,
|
probe.live_record_count,
|
||||||
probe.live_id_bound,
|
probe.live_id_bound,
|
||||||
probe.direct_record_stride,
|
probe.direct_record_stride,
|
||||||
|
|
@ -3120,15 +3123,26 @@ pub fn load_save_slice_from_report(
|
||||||
probe.close_tag_offset
|
probe.close_tag_offset
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(probe) = &report.save_region_collection_directory_probe {
|
if let Some(probe) = &report.save_train_collection_directory_probe {
|
||||||
notes.push(format!(
|
notes.push(format!(
|
||||||
"Raw save tagged region metadata also exposes a live-entry directory with {} entries rooted at metadata dword {} (head={:?}, tail={:?}).",
|
"Raw save tagged train metadata also exposes a live-entry directory with {} entries rooted at metadata dword {} (head={:?}, tail={:?}).",
|
||||||
probe.entries.len(),
|
probe.entries.len(),
|
||||||
probe.directory_root_dword_index,
|
probe.directory_root_dword_index,
|
||||||
probe.chain_head_live_entry_id,
|
probe.chain_head_live_entry_id,
|
||||||
probe.chain_tail_live_entry_id
|
probe.chain_tail_live_entry_id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(probe) = &report.save_region_collection_header_probe {
|
||||||
|
notes.push(format!(
|
||||||
|
"Raw save tagged region header reports live_record_count={} and live_id_bound={} with serialized stride hint 0x{:x} at file offsets 0x{:x}/0x{:x}/0x{:x}.",
|
||||||
|
probe.live_record_count,
|
||||||
|
probe.live_id_bound,
|
||||||
|
probe.direct_record_stride,
|
||||||
|
probe.metadata_tag_offset,
|
||||||
|
probe.records_tag_offset,
|
||||||
|
probe.close_tag_offset
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(probe) = &report.save_placed_structure_collection_header_probe {
|
if let Some(probe) = &report.save_placed_structure_collection_header_probe {
|
||||||
notes.push(format!(
|
notes.push(format!(
|
||||||
"Raw save tagged placed-structure header reports live_record_count={} and live_id_bound={} with serialized stride hint 0x{:x} at file offsets 0x{:x}/0x{:x}/0x{:x}.",
|
"Raw save tagged placed-structure header reports live_record_count={} and live_id_bound={} with serialized stride hint 0x{:x} at file offsets 0x{:x}/0x{:x}/0x{:x}.",
|
||||||
|
|
@ -3200,7 +3214,7 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
|
||||||
let world_issue_37 = report.save_world_issue_37_probe.clone();
|
let world_issue_37 = report.save_world_issue_37_probe.clone();
|
||||||
let world_economic_tuning = report.save_world_economic_tuning_probe.clone();
|
let world_economic_tuning = report.save_world_economic_tuning_probe.clone();
|
||||||
let world_finance_neighborhood = report.save_world_finance_neighborhood_probe.clone();
|
let world_finance_neighborhood = report.save_world_finance_neighborhood_probe.clone();
|
||||||
let region_collection_directory = report.save_region_collection_directory_probe.clone();
|
let train_collection_directory = report.save_train_collection_directory_probe.clone();
|
||||||
let company_header_probe = report.save_company_collection_header_probe.as_ref();
|
let company_header_probe = report.save_company_collection_header_probe.as_ref();
|
||||||
let chairman_header_probe = report
|
let chairman_header_probe = report
|
||||||
.save_chairman_profile_collection_header_probe
|
.save_chairman_profile_collection_header_probe
|
||||||
|
|
@ -3490,21 +3504,27 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(header) = report.save_region_collection_header_probe.as_ref() {
|
if let Some(header) = report.save_train_collection_header_probe.as_ref() {
|
||||||
notes.push(format!(
|
notes.push(format!(
|
||||||
"Region analysis now also exports the tagged region collection header: live_record_count={} live_id_bound={} direct_record_stride=0x{:x}.",
|
"Train analysis now also exports the tagged train collection header: live_record_count={} live_id_bound={} direct_record_stride=0x{:x}.",
|
||||||
header.live_record_count, header.live_id_bound, header.direct_record_stride
|
header.live_record_count, header.live_id_bound, header.direct_record_stride
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(directory) = region_collection_directory.as_ref() {
|
if let Some(directory) = train_collection_directory.as_ref() {
|
||||||
notes.push(format!(
|
notes.push(format!(
|
||||||
"Region analysis now also exports the tagged live-entry directory rooted at metadata dword {}: {} entries chained from {:?} to {:?}.",
|
"Train analysis now also exports the tagged live-entry directory rooted at metadata dword {}: {} entries chained from {:?} to {:?}.",
|
||||||
directory.directory_root_dword_index,
|
directory.directory_root_dword_index,
|
||||||
directory.entries.len(),
|
directory.entries.len(),
|
||||||
directory.chain_head_live_entry_id,
|
directory.chain_head_live_entry_id,
|
||||||
directory.chain_tail_live_entry_id
|
directory.chain_tail_live_entry_id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(header) = report.save_region_collection_header_probe.as_ref() {
|
||||||
|
notes.push(format!(
|
||||||
|
"Region analysis now also exports the non-direct tagged region collection header: live_record_count={} live_id_bound={} serialized_stride_hint=0x{:x}.",
|
||||||
|
header.live_record_count, header.live_id_bound, header.direct_record_stride
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(header) = report
|
if let Some(header) = report
|
||||||
.save_placed_structure_collection_header_probe
|
.save_placed_structure_collection_header_probe
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -3555,8 +3575,9 @@ pub fn inspect_save_company_and_chairman_analysis_bytes(
|
||||||
world_issue_37,
|
world_issue_37,
|
||||||
world_economic_tuning,
|
world_economic_tuning,
|
||||||
world_finance_neighborhood,
|
world_finance_neighborhood,
|
||||||
|
train_collection_header: report.save_train_collection_header_probe.clone(),
|
||||||
|
train_collection_directory,
|
||||||
region_collection_header: report.save_region_collection_header_probe.clone(),
|
region_collection_header: report.save_region_collection_header_probe.clone(),
|
||||||
region_collection_directory,
|
|
||||||
placed_structure_collection_header: report
|
placed_structure_collection_header: report
|
||||||
.save_placed_structure_collection_header_probe
|
.save_placed_structure_collection_header_probe
|
||||||
.clone(),
|
.clone(),
|
||||||
|
|
@ -7558,14 +7579,19 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
|
||||||
file_extension_hint.as_deref(),
|
file_extension_hint.as_deref(),
|
||||||
container_profile.as_ref(),
|
container_profile.as_ref(),
|
||||||
);
|
);
|
||||||
let save_region_collection_header_probe = parse_save_region_collection_header_probe(
|
let save_train_collection_header_probe = parse_save_train_collection_header_probe(
|
||||||
bytes,
|
bytes,
|
||||||
file_extension_hint.as_deref(),
|
file_extension_hint.as_deref(),
|
||||||
container_profile.as_ref(),
|
container_profile.as_ref(),
|
||||||
);
|
);
|
||||||
let save_region_collection_directory_probe = parse_save_region_collection_directory_probe(
|
let save_train_collection_directory_probe = parse_save_train_collection_directory_probe(
|
||||||
bytes,
|
bytes,
|
||||||
save_region_collection_header_probe.as_ref(),
|
save_train_collection_header_probe.as_ref(),
|
||||||
|
);
|
||||||
|
let save_region_collection_header_probe = parse_save_region_collection_header_probe(
|
||||||
|
bytes,
|
||||||
|
file_extension_hint.as_deref(),
|
||||||
|
container_profile.as_ref(),
|
||||||
);
|
);
|
||||||
let save_placed_structure_collection_header_probe =
|
let save_placed_structure_collection_header_probe =
|
||||||
parse_save_placed_structure_collection_header_probe(
|
parse_save_placed_structure_collection_header_probe(
|
||||||
|
|
@ -7736,8 +7762,9 @@ fn inspect_bundle_bytes(bytes: &[u8], file_extension_hint: Option<String>) -> Sm
|
||||||
save_world_finance_neighborhood_probe,
|
save_world_finance_neighborhood_probe,
|
||||||
save_company_collection_header_probe,
|
save_company_collection_header_probe,
|
||||||
save_chairman_profile_collection_header_probe,
|
save_chairman_profile_collection_header_probe,
|
||||||
|
save_train_collection_header_probe,
|
||||||
|
save_train_collection_directory_probe,
|
||||||
save_region_collection_header_probe,
|
save_region_collection_header_probe,
|
||||||
save_region_collection_directory_probe,
|
|
||||||
save_placed_structure_collection_header_probe,
|
save_placed_structure_collection_header_probe,
|
||||||
save_company_roster_probe,
|
save_company_roster_probe,
|
||||||
save_chairman_profile_table_probe,
|
save_chairman_profile_table_probe,
|
||||||
|
|
@ -9677,7 +9704,7 @@ fn parse_save_chairman_profile_collection_header_probe(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_save_region_collection_header_probe(
|
fn parse_save_train_collection_header_probe(
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
file_extension_hint: Option<&str>,
|
file_extension_hint: Option<&str>,
|
||||||
container_profile: Option<&SmpContainerProfile>,
|
container_profile: Option<&SmpContainerProfile>,
|
||||||
|
|
@ -9689,8 +9716,8 @@ fn parse_save_region_collection_header_probe(
|
||||||
0x00005209,
|
0x00005209,
|
||||||
0x0000520a,
|
0x0000520a,
|
||||||
0x0000520b,
|
0x0000520b,
|
||||||
"save-region-tagged-header-counts",
|
"save-train-tagged-header-counts",
|
||||||
"scenario-save-region-header-counts",
|
"scenario-save-train-header-counts",
|
||||||
|header| {
|
|header| {
|
||||||
header.direct_collection_flag == 1
|
header.direct_collection_flag == 1
|
||||||
&& header.direct_record_stride >= 0x100
|
&& header.direct_record_stride >= 0x100
|
||||||
|
|
@ -9701,18 +9728,18 @@ fn parse_save_region_collection_header_probe(
|
||||||
&& header.live_record_count <= header.live_id_bound
|
&& header.live_record_count <= header.live_id_bound
|
||||||
},
|
},
|
||||||
vec![
|
vec![
|
||||||
"save-side live region collection shares tagged header family 0x5209/0x520a/0x520b with other indexed direct-record bundles".to_string(),
|
"save-side live train collection shares tagged header family 0x5209/0x520a/0x520b with other indexed direct-record bundles".to_string(),
|
||||||
"the grounded region-side candidate is the smaller direct-record family with stride 0x1d5 and live_id_bound/count in the city-region range, not the larger chairman/profile family".to_string(),
|
"the grounded train-side candidate is the smaller direct-record family with stride 0x1d5 whose metadata payload carries Train N labels, distinct from the larger chairman/profile family and the non-direct region family".to_string(),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_save_region_collection_directory_probe(
|
fn parse_save_train_collection_directory_probe(
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
header_probe: Option<&SmpSaveTaggedCollectionHeaderProbe>,
|
header_probe: Option<&SmpSaveTaggedCollectionHeaderProbe>,
|
||||||
) -> Option<SmpSaveRegionCollectionDirectoryProbe> {
|
) -> Option<SmpSaveTrainCollectionDirectoryProbe> {
|
||||||
let header_probe = header_probe?;
|
let header_probe = header_probe?;
|
||||||
if header_probe.source_kind != "save-region-tagged-header-counts" {
|
if header_probe.source_kind != "save-train-tagged-header-counts" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let metadata_payload =
|
let metadata_payload =
|
||||||
|
|
@ -9733,7 +9760,7 @@ fn parse_save_region_collection_directory_probe(
|
||||||
let payload_relative_offset = read_u32_at(directory_bytes, entry_offset)?;
|
let payload_relative_offset = read_u32_at(directory_bytes, entry_offset)?;
|
||||||
let previous_live_entry_id = read_u32_at(directory_bytes, entry_offset + 4)?;
|
let previous_live_entry_id = read_u32_at(directory_bytes, entry_offset + 4)?;
|
||||||
let next_live_entry_id = read_u32_at(directory_bytes, entry_offset + 8)?;
|
let next_live_entry_id = read_u32_at(directory_bytes, entry_offset + 8)?;
|
||||||
entries.push(SmpSaveRegionCollectionDirectoryEntryProbe {
|
entries.push(SmpSaveTrainCollectionDirectoryEntryProbe {
|
||||||
live_entry_id: (index + 1) as u32,
|
live_entry_id: (index + 1) as u32,
|
||||||
payload_relative_offset,
|
payload_relative_offset,
|
||||||
payload_relative_offset_hex: format!("0x{payload_relative_offset:08x}"),
|
payload_relative_offset_hex: format!("0x{payload_relative_offset:08x}"),
|
||||||
|
|
@ -9758,10 +9785,10 @@ fn parse_save_region_collection_directory_probe(
|
||||||
let monotonic_offsets = entries
|
let monotonic_offsets = entries
|
||||||
.windows(2)
|
.windows(2)
|
||||||
.all(|window| window[0].payload_relative_offset < window[1].payload_relative_offset);
|
.all(|window| window[0].payload_relative_offset < window[1].payload_relative_offset);
|
||||||
Some(SmpSaveRegionCollectionDirectoryProbe {
|
Some(SmpSaveTrainCollectionDirectoryProbe {
|
||||||
profile_family: header_probe.profile_family.clone(),
|
profile_family: header_probe.profile_family.clone(),
|
||||||
source_kind: "save-region-live-directory".to_string(),
|
source_kind: "save-train-live-directory".to_string(),
|
||||||
semantic_family: "scenario-save-region-live-directory".to_string(),
|
semantic_family: "scenario-save-train-live-directory".to_string(),
|
||||||
metadata_tag_offset: header_probe.metadata_tag_offset,
|
metadata_tag_offset: header_probe.metadata_tag_offset,
|
||||||
records_tag_offset: header_probe.records_tag_offset,
|
records_tag_offset: header_probe.records_tag_offset,
|
||||||
close_tag_offset: header_probe.close_tag_offset,
|
close_tag_offset: header_probe.close_tag_offset,
|
||||||
|
|
@ -9773,9 +9800,9 @@ fn parse_save_region_collection_directory_probe(
|
||||||
chain_tail_live_entry_id,
|
chain_tail_live_entry_id,
|
||||||
entries,
|
entries,
|
||||||
evidence: vec![
|
evidence: vec![
|
||||||
"save-side region metadata payload exposes a live-entry directory immediately after the first 16 dwords, before the records tag".to_string(),
|
"save-side train metadata payload exposes a live-entry directory immediately after the first 16 dwords, with payload-relative offsets pointing into the later records span".to_string(),
|
||||||
format!(
|
format!(
|
||||||
"region live directory decodes {} triplets of (payload_relative_offset, prev_live_entry_id, next_live_entry_id) from metadata dword index {}",
|
"train live directory decodes {} triplets of (payload_relative_offset, prev_live_entry_id, next_live_entry_id) from metadata dword index {}",
|
||||||
header_probe.live_record_count,
|
header_probe.live_record_count,
|
||||||
SAVE_REGION_COLLECTION_DIRECTORY_ROOT_DWORD_INDEX
|
SAVE_REGION_COLLECTION_DIRECTORY_ROOT_DWORD_INDEX
|
||||||
),
|
),
|
||||||
|
|
@ -9787,6 +9814,42 @@ fn parse_save_region_collection_directory_probe(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_save_region_collection_header_probe(
|
||||||
|
bytes: &[u8],
|
||||||
|
file_extension_hint: Option<&str>,
|
||||||
|
container_profile: Option<&SmpContainerProfile>,
|
||||||
|
) -> Option<SmpSaveTaggedCollectionHeaderProbe> {
|
||||||
|
let probe = parse_save_tagged_collection_header_probe(
|
||||||
|
bytes,
|
||||||
|
file_extension_hint,
|
||||||
|
container_profile,
|
||||||
|
0x00005209,
|
||||||
|
0x0000520a,
|
||||||
|
0x0000520b,
|
||||||
|
"save-region-tagged-header-counts",
|
||||||
|
"scenario-save-region-header-counts",
|
||||||
|
|header| {
|
||||||
|
header.direct_collection_flag == 0
|
||||||
|
&& header.direct_record_stride == 0x06
|
||||||
|
&& header.live_id_bound >= 0x80
|
||||||
|
&& header.live_id_bound <= 0x200
|
||||||
|
&& header.live_record_count >= 0x80
|
||||||
|
&& header.live_record_count <= header.live_id_bound
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
"save-side live region collection shares tagged header family 0x5209/0x520a/0x520b with trains and chairman profiles, but uses the larger non-direct indexed family".to_string(),
|
||||||
|
"the grounded region-side candidate is the non-direct 0x5209 family with live_id_bound/count in the 0x96/0x91 range and Marker09-style default stems in the records span, distinct from the smaller direct train family".to_string(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let records_preview = bytes
|
||||||
|
.get(probe.records_tag_offset + 4..probe.close_tag_offset)
|
||||||
|
.unwrap_or(&[]);
|
||||||
|
records_preview
|
||||||
|
.windows("Marker09".len())
|
||||||
|
.any(|window| window == b"Marker09")
|
||||||
|
.then_some(probe)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_save_placed_structure_collection_header_probe(
|
fn parse_save_placed_structure_collection_header_probe(
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
file_extension_hint: Option<&str>,
|
file_extension_hint: Option<&str>,
|
||||||
|
|
@ -16657,10 +16720,10 @@ mod tests {
|
||||||
],
|
],
|
||||||
evidence: vec![],
|
evidence: vec![],
|
||||||
});
|
});
|
||||||
report.save_region_collection_header_probe = Some(SmpSaveTaggedCollectionHeaderProbe {
|
report.save_train_collection_header_probe = Some(SmpSaveTaggedCollectionHeaderProbe {
|
||||||
profile_family: "rt3-105-save-container-v1".to_string(),
|
profile_family: "rt3-105-save-container-v1".to_string(),
|
||||||
source_kind: "save-region-tagged-header-counts".to_string(),
|
source_kind: "save-train-tagged-header-counts".to_string(),
|
||||||
semantic_family: "scenario-save-region-header-counts".to_string(),
|
semantic_family: "scenario-save-train-header-counts".to_string(),
|
||||||
metadata_tag_offset: 0x3000,
|
metadata_tag_offset: 0x3000,
|
||||||
records_tag_offset: 0x3100,
|
records_tag_offset: 0x3100,
|
||||||
close_tag_offset: 0x3200,
|
close_tag_offset: 0x3200,
|
||||||
|
|
@ -16683,11 +16746,10 @@ mod tests {
|
||||||
],
|
],
|
||||||
evidence: vec![],
|
evidence: vec![],
|
||||||
});
|
});
|
||||||
report.save_region_collection_directory_probe =
|
report.save_train_collection_directory_probe = Some(SmpSaveTrainCollectionDirectoryProbe {
|
||||||
Some(SmpSaveRegionCollectionDirectoryProbe {
|
|
||||||
profile_family: "rt3-105-save-container-v1".to_string(),
|
profile_family: "rt3-105-save-container-v1".to_string(),
|
||||||
source_kind: "save-region-live-directory".to_string(),
|
source_kind: "save-train-live-directory".to_string(),
|
||||||
semantic_family: "scenario-save-region-live-directory".to_string(),
|
semantic_family: "scenario-save-train-live-directory".to_string(),
|
||||||
metadata_tag_offset: 0x3000,
|
metadata_tag_offset: 0x3000,
|
||||||
records_tag_offset: 0x3100,
|
records_tag_offset: 0x3100,
|
||||||
close_tag_offset: 0x3200,
|
close_tag_offset: 0x3200,
|
||||||
|
|
@ -16698,7 +16760,7 @@ mod tests {
|
||||||
chain_head_live_entry_id: Some(1),
|
chain_head_live_entry_id: Some(1),
|
||||||
chain_tail_live_entry_id: Some(20),
|
chain_tail_live_entry_id: Some(20),
|
||||||
entries: vec![
|
entries: vec![
|
||||||
SmpSaveRegionCollectionDirectoryEntryProbe {
|
SmpSaveTrainCollectionDirectoryEntryProbe {
|
||||||
live_entry_id: 1,
|
live_entry_id: 1,
|
||||||
payload_relative_offset: 0x2af8,
|
payload_relative_offset: 0x2af8,
|
||||||
payload_relative_offset_hex: "0x00002af8".to_string(),
|
payload_relative_offset_hex: "0x00002af8".to_string(),
|
||||||
|
|
@ -16708,7 +16770,7 @@ mod tests {
|
||||||
next_live_entry_id: 2,
|
next_live_entry_id: 2,
|
||||||
next_live_entry_id_hex: "0x00000002".to_string(),
|
next_live_entry_id_hex: "0x00000002".to_string(),
|
||||||
},
|
},
|
||||||
SmpSaveRegionCollectionDirectoryEntryProbe {
|
SmpSaveTrainCollectionDirectoryEntryProbe {
|
||||||
live_entry_id: 2,
|
live_entry_id: 2,
|
||||||
payload_relative_offset: 0x2ee0,
|
payload_relative_offset: 0x2ee0,
|
||||||
payload_relative_offset_hex: "0x00002ee0".to_string(),
|
payload_relative_offset_hex: "0x00002ee0".to_string(),
|
||||||
|
|
@ -16721,6 +16783,32 @@ mod tests {
|
||||||
],
|
],
|
||||||
evidence: vec![],
|
evidence: vec![],
|
||||||
});
|
});
|
||||||
|
report.save_region_collection_header_probe = Some(SmpSaveTaggedCollectionHeaderProbe {
|
||||||
|
profile_family: "rt3-105-save-container-v1".to_string(),
|
||||||
|
source_kind: "save-region-tagged-header-counts".to_string(),
|
||||||
|
semantic_family: "scenario-save-region-header-counts".to_string(),
|
||||||
|
metadata_tag_offset: 0x5000,
|
||||||
|
records_tag_offset: 0x5100,
|
||||||
|
close_tag_offset: 0x5200,
|
||||||
|
direct_collection_flag: 0,
|
||||||
|
direct_collection_flag_hex: "0x00000000".to_string(),
|
||||||
|
direct_record_stride: 0x06,
|
||||||
|
direct_record_stride_hex: "0x00000006".to_string(),
|
||||||
|
live_id_bound: 0x96,
|
||||||
|
live_id_bound_hex: "0x00000096".to_string(),
|
||||||
|
live_record_count: 0x91,
|
||||||
|
live_record_count_hex: "0x00000091".to_string(),
|
||||||
|
header_words: vec![0, 6, 0x0a, 0x14, 0x96, 0x91],
|
||||||
|
header_hex_words: vec![
|
||||||
|
"0x00000000".to_string(),
|
||||||
|
"0x00000006".to_string(),
|
||||||
|
"0x0000000a".to_string(),
|
||||||
|
"0x00000014".to_string(),
|
||||||
|
"0x00000096".to_string(),
|
||||||
|
"0x00000091".to_string(),
|
||||||
|
],
|
||||||
|
evidence: vec![],
|
||||||
|
});
|
||||||
report.save_placed_structure_collection_header_probe =
|
report.save_placed_structure_collection_header_probe =
|
||||||
Some(SmpSaveTaggedCollectionHeaderProbe {
|
Some(SmpSaveTaggedCollectionHeaderProbe {
|
||||||
profile_family: "rt3-105-save-container-v1".to_string(),
|
profile_family: "rt3-105-save-container-v1".to_string(),
|
||||||
|
|
@ -16818,11 +16906,16 @@ mod tests {
|
||||||
slice
|
slice
|
||||||
.notes
|
.notes
|
||||||
.iter()
|
.iter()
|
||||||
.any(|note| note.contains("tagged region header reports live_record_count=20"))
|
.any(|note| note.contains("tagged train header reports live_record_count=20"))
|
||||||
);
|
);
|
||||||
assert!(slice.notes.iter().any(|note| {
|
assert!(slice.notes.iter().any(|note| {
|
||||||
note.contains("tagged region metadata also exposes a live-entry directory")
|
note.contains("tagged train metadata also exposes a live-entry directory")
|
||||||
}));
|
}));
|
||||||
|
assert!(
|
||||||
|
slice.notes.iter().any(|note| {
|
||||||
|
note.contains("tagged region header reports live_record_count=145")
|
||||||
|
})
|
||||||
|
);
|
||||||
assert!(slice.notes.iter().any(|note| {
|
assert!(slice.notes.iter().any(|note| {
|
||||||
note.contains("tagged placed-structure header reports live_record_count=2026")
|
note.contains("tagged placed-structure header reports live_record_count=2026")
|
||||||
}));
|
}));
|
||||||
|
|
@ -16905,7 +16998,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_region_tagged_collection_header_probe_from_exact_u32_tags() {
|
fn parses_train_tagged_collection_header_probe_from_exact_u32_tags() {
|
||||||
let mut bytes = vec![0u8; 0x400];
|
let mut bytes = vec![0u8; 0x400];
|
||||||
let metadata_tag_offset = 0x40usize;
|
let metadata_tag_offset = 0x40usize;
|
||||||
let records_tag_offset = 0x140usize;
|
let records_tag_offset = 0x140usize;
|
||||||
|
|
@ -16923,6 +17016,47 @@ mod tests {
|
||||||
bytes[offset..offset + 4].copy_from_slice(&word.to_le_bytes());
|
bytes[offset..offset + 4].copy_from_slice(&word.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let probe = parse_save_train_collection_header_probe(
|
||||||
|
&bytes,
|
||||||
|
Some("gms"),
|
||||||
|
Some(&SmpContainerProfile {
|
||||||
|
profile_family: "rt3-105-save-container-v1".to_string(),
|
||||||
|
profile_evidence: vec![],
|
||||||
|
is_known_profile: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("train header probe should parse");
|
||||||
|
|
||||||
|
assert_eq!(probe.metadata_tag_offset, metadata_tag_offset);
|
||||||
|
assert_eq!(probe.records_tag_offset, records_tag_offset);
|
||||||
|
assert_eq!(probe.close_tag_offset, close_tag_offset);
|
||||||
|
assert_eq!(probe.direct_collection_flag, 1);
|
||||||
|
assert_eq!(probe.direct_record_stride, 0x1d5);
|
||||||
|
assert_eq!(probe.live_id_bound, 0x32);
|
||||||
|
assert_eq!(probe.live_record_count, 0x14);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_region_tagged_collection_header_probe_from_marker09_family() {
|
||||||
|
let mut bytes = vec![0u8; 0x400];
|
||||||
|
let metadata_tag_offset = 0x40usize;
|
||||||
|
let records_tag_offset = 0x180usize;
|
||||||
|
let close_tag_offset = 0x1c0usize;
|
||||||
|
bytes[metadata_tag_offset..metadata_tag_offset + 4]
|
||||||
|
.copy_from_slice(&0x00005209u32.to_le_bytes());
|
||||||
|
bytes[records_tag_offset..records_tag_offset + 4]
|
||||||
|
.copy_from_slice(&0x0000520au32.to_le_bytes());
|
||||||
|
bytes[close_tag_offset..close_tag_offset + 4].copy_from_slice(&0x0000520bu32.to_le_bytes());
|
||||||
|
let header_words = [
|
||||||
|
0u32, 0x06, 0x0a, 0x14, 0x96, 0x91, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
];
|
||||||
|
for (index, word) in header_words.into_iter().enumerate() {
|
||||||
|
let offset = metadata_tag_offset + 4 + index * 4;
|
||||||
|
bytes[offset..offset + 4].copy_from_slice(&word.to_le_bytes());
|
||||||
|
}
|
||||||
|
let marker_offset = records_tag_offset + 4 + 0x20;
|
||||||
|
bytes[marker_offset..marker_offset + 8].copy_from_slice(b"Marker09");
|
||||||
|
|
||||||
let probe = parse_save_region_collection_header_probe(
|
let probe = parse_save_region_collection_header_probe(
|
||||||
&bytes,
|
&bytes,
|
||||||
Some("gms"),
|
Some("gms"),
|
||||||
|
|
@ -16937,14 +17071,14 @@ mod tests {
|
||||||
assert_eq!(probe.metadata_tag_offset, metadata_tag_offset);
|
assert_eq!(probe.metadata_tag_offset, metadata_tag_offset);
|
||||||
assert_eq!(probe.records_tag_offset, records_tag_offset);
|
assert_eq!(probe.records_tag_offset, records_tag_offset);
|
||||||
assert_eq!(probe.close_tag_offset, close_tag_offset);
|
assert_eq!(probe.close_tag_offset, close_tag_offset);
|
||||||
assert_eq!(probe.direct_collection_flag, 1);
|
assert_eq!(probe.direct_collection_flag, 0);
|
||||||
assert_eq!(probe.direct_record_stride, 0x1d5);
|
assert_eq!(probe.direct_record_stride, 0x06);
|
||||||
assert_eq!(probe.live_id_bound, 0x32);
|
assert_eq!(probe.live_id_bound, 0x96);
|
||||||
assert_eq!(probe.live_record_count, 0x14);
|
assert_eq!(probe.live_record_count, 0x91);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_region_collection_directory_probe_from_tagged_metadata_triplets() {
|
fn parses_train_collection_directory_probe_from_tagged_metadata_triplets() {
|
||||||
let mut bytes = vec![0u8; 0x400];
|
let mut bytes = vec![0u8; 0x400];
|
||||||
let metadata_tag_offset = 0x40usize;
|
let metadata_tag_offset = 0x40usize;
|
||||||
let records_tag_offset = 0x180usize;
|
let records_tag_offset = 0x180usize;
|
||||||
|
|
@ -16971,7 +17105,7 @@ mod tests {
|
||||||
bytes[base + 8..base + 12].copy_from_slice(&next.to_le_bytes());
|
bytes[base + 8..base + 12].copy_from_slice(&next.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
let header_probe = parse_save_region_collection_header_probe(
|
let header_probe = parse_save_train_collection_header_probe(
|
||||||
&bytes,
|
&bytes,
|
||||||
Some("gms"),
|
Some("gms"),
|
||||||
Some(&SmpContainerProfile {
|
Some(&SmpContainerProfile {
|
||||||
|
|
@ -16980,10 +17114,10 @@ mod tests {
|
||||||
is_known_profile: true,
|
is_known_profile: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.expect("region header probe should parse");
|
.expect("train header probe should parse");
|
||||||
let directory_probe =
|
let directory_probe =
|
||||||
parse_save_region_collection_directory_probe(&bytes, Some(&header_probe))
|
parse_save_train_collection_directory_probe(&bytes, Some(&header_probe))
|
||||||
.expect("region directory probe should parse");
|
.expect("train directory probe should parse");
|
||||||
|
|
||||||
assert_eq!(directory_probe.directory_root_dword_index, 16);
|
assert_eq!(directory_probe.directory_root_dword_index, 16);
|
||||||
assert_eq!(directory_probe.live_record_count, 3);
|
assert_eq!(directory_probe.live_record_count, 3);
|
||||||
|
|
|
||||||
|
|
@ -191,11 +191,12 @@ The highest-value next passes are now:
|
||||||
electric-only periodic-company override rewrites the world route-preference byte to the
|
electric-only periodic-company override rewrites the world route-preference byte to the
|
||||||
effective company preference, ending it restores the base world byte, and runtime service state
|
effective company preference, ending it restores the base world byte, and runtime service state
|
||||||
now carries both the active and last applied override
|
now carries both the active and last applied override
|
||||||
- save inspection now also exposes the tagged live region header `0x5209/0x520a/0x520b` plus its
|
- save inspection now also separates the shared `0x5209/0x520a/0x520b` save family correctly: the
|
||||||
live-entry directory rooted at metadata dword `16`, and the tagged placed-structure header
|
smaller direct `0x1d5` collection is the live train family and now exposes a live-entry
|
||||||
`0x36b1/0x36b2/0x36b3`, as first-class owner seams, so the remaining city-connection /
|
directory rooted at metadata dword `16`, while the actual region collection is the larger
|
||||||
linked-transit blocker is record-body reconstruction rather than missing save-side collection
|
non-direct `Marker09` family; the tagged placed-structure header `0x36b1/0x36b2/0x36b3`
|
||||||
identity
|
remains grounded alongside them, so the remaining city-connection / linked-transit blocker is
|
||||||
|
record-body reconstruction rather than missing save-side collection identity
|
||||||
- the project rule on the remaining closure work is now explicit too: when one runtime-facing field
|
- the project rule on the remaining closure work is now explicit too: when one runtime-facing field
|
||||||
is still ambiguous, prefer rehosting the owning source state or real reader/setter family first
|
is still ambiguous, prefer rehosting the owning source state or real reader/setter family first
|
||||||
instead of guessing another derived leaf field from neighboring raw offsets; the checked-in
|
instead of guessing another derived leaf field from neighboring raw offsets; the checked-in
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ Working rule:
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- Reconstruct the save-side region record body on top of the newly grounded tagged region
|
- Reconstruct the save-side region record body on top of the newly corrected non-direct tagged
|
||||||
header-plus-directory seam, especially the pending bonus lane `[region+0x276]`, completion latch
|
region seam (`0x5209/0x520a/0x520b`, stride hint `0x06`, `Marker09` record stems), especially
|
||||||
`[region+0x302]`, one-shot notice latch `[region+0x316]`, severity/source lane
|
the pending bonus lane `[region+0x276]`, completion latch `[region+0x302]`, one-shot notice
|
||||||
`[region+0x25e]`, and any stable region-id or class discriminator that can drive shellless
|
latch `[region+0x316]`, severity/source lane `[region+0x25e]`, and any stable region-id or
|
||||||
city-connection service.
|
class discriminator that can drive shellless city-connection service.
|
||||||
- Reconstruct the save-side placed-structure collection body on top of the newly grounded
|
- Reconstruct the save-side placed-structure collection body on top of the newly grounded
|
||||||
`0x36b1/0x36b2/0x36b3` header seam so the blocked city-connection / linked-transit branch can
|
`0x36b1/0x36b2/0x36b3` header seam so the blocked city-connection / linked-transit branch can
|
||||||
stop depending on atlas-only placed-structure and local-runtime refresh notes.
|
stop depending on atlas-only placed-structure and local-runtime refresh notes.
|
||||||
|
|
@ -44,17 +44,18 @@ Working rule:
|
||||||
- Any candidate slice that requires guessing rather than rehosting owning state or real
|
- Any candidate slice that requires guessing rather than rehosting owning state or real
|
||||||
reader/setter families stays blocked until a better owner seam is grounded.
|
reader/setter families stays blocked until a better owner seam is grounded.
|
||||||
- The city-connection announcement / linked-transit roster-maintenance branch is still blocked at
|
- The city-connection announcement / linked-transit roster-maintenance branch is still blocked at
|
||||||
the record-body level, not the collection-identity level: the runtime now has a tagged region
|
the record-body level, not the collection-identity level: the runtime now has a corrected
|
||||||
header-plus-directory seam and a tagged placed-structure header seam, but it does not yet
|
non-direct tagged region seam, a tagged train header-plus-directory seam, and a tagged
|
||||||
reconstruct the live region or placed-structure record bodies those service owners need.
|
placed-structure header seam, but it does not yet reconstruct the live region or
|
||||||
|
placed-structure record bodies those service owners need.
|
||||||
|
|
||||||
## Recently Done
|
## Recently Done
|
||||||
|
|
||||||
- Save inspection now exposes the tagged region collection header (`0x5209/0x520a/0x520b`,
|
- Save inspection now splits the shared `0x5209/0x520a/0x520b` family correctly: the smaller
|
||||||
stride `0x1d5`) plus the live-entry directory rooted at metadata dword `16`, and the tagged
|
direct `0x1d5` collection is the live train family and now exposes a live-entry directory rooted
|
||||||
placed-structure collection header (`0x36b1/0x36b2/0x36b3`) as first-class owner seams, so the
|
at metadata dword `16`, while the actual region family is the larger non-direct `Marker09`
|
||||||
blocked city-connection / linked-transit queue now has grounded save-side collection counts,
|
collection with live_id/count `0x96/0x91`; the tagged placed-structure header
|
||||||
ids, and region payload offsets to build on instead of only atlas notes.
|
(`0x36b1/0x36b2/0x36b3`) remains grounded alongside them.
|
||||||
- Stepped calendar progression now also refreshes save-world owner time fields, including packed
|
- Stepped calendar progression now also refreshes save-world owner time fields, including packed
|
||||||
year, packed tuple words, absolute counter, and the derived selected-year gap scalar.
|
year, packed tuple words, absolute counter, and the derived selected-year gap scalar.
|
||||||
- Automatic year-rollover calendar stepping now invokes periodic-boundary service.
|
- Automatic year-rollover calendar stepping now invokes periodic-boundary service.
|
||||||
|
|
|
||||||
|
|
@ -269,11 +269,12 @@ electric-only periodic-company override rewrites `[world+0x4c74]` to the effecti
|
||||||
preference for the active service pass, ending the override restores the base world byte, and
|
preference for the active service pass, ending the override restores the base world byte, and
|
||||||
runtime service state now carries both the active and last applied override instead of leaving the
|
runtime service state now carries both the active and last applied override instead of leaving the
|
||||||
route-preference seam as a pure reader note.
|
route-preference seam as a pure reader note.
|
||||||
Save inspection now also exposes the tagged live region header `0x5209/0x520a/0x520b` plus its
|
Save inspection now also separates the shared `0x5209/0x520a/0x520b` save family correctly: the
|
||||||
live-entry directory rooted at metadata dword `16`, and the tagged placed-structure header
|
smaller direct `0x1d5` collection is the live train family and now exposes a live-entry
|
||||||
`0x36b1/0x36b2/0x36b3`, as first-class owner seams, so the remaining city-connection /
|
directory rooted at metadata dword `16`, while the actual region collection is the larger
|
||||||
linked-transit blocker is record-body reconstruction rather than missing save-side collection
|
non-direct `Marker09` family. The tagged placed-structure header `0x36b1/0x36b2/0x36b3` remains
|
||||||
identity.
|
grounded alongside them, so the remaining city-connection / linked-transit blocker is record-body
|
||||||
|
reconstruction rather than missing save-side collection identity.
|
||||||
That same seam now also carries the fixed-world building-density growth setting plus the linked
|
That same seam now also carries the fixed-world building-density growth setting plus the linked
|
||||||
chairman personality byte, which is enough to rehost the annual stock-repurchase gate on owned
|
chairman personality byte, which is enough to rehost the annual stock-repurchase gate on owned
|
||||||
save/runtime state instead of another threshold-only note. The stock-capital issue branch now
|
save/runtime state instead of another threshold-only note. The stock-capital issue branch now
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue