Summarize packaged engine type profiles
This commit is contained in:
parent
ee98ab8302
commit
4801b1a164
3 changed files with 136 additions and 13 deletions
|
|
@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::imb::{ImbInspectionReport, inspect_imb_bytes};
|
||||||
use super::pk4::inspect_pk4_file;
|
use super::pk4::inspect_pk4_file;
|
||||||
|
|
||||||
const CAR_PRIMARY_DISPLAY_NAME_OFFSET: usize = 0x0c;
|
const CAR_PRIMARY_DISPLAY_NAME_OFFSET: usize = 0x0c;
|
||||||
|
|
@ -114,6 +115,18 @@ pub struct EngineTypeLocomotiveDisplayCensusReport {
|
||||||
pub notes: Vec<String>,
|
pub notes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EngineTypeImbProfileSummary {
|
||||||
|
pub tga_name: Option<String>,
|
||||||
|
pub texture_width: Option<i64>,
|
||||||
|
pub texture_height: Option<i64>,
|
||||||
|
pub target_screen_width: Option<i64>,
|
||||||
|
pub target_screen_height: Option<i64>,
|
||||||
|
pub horizontal_scale_modifier: Option<f64>,
|
||||||
|
pub max_percent_of_interface_vram: Option<f64>,
|
||||||
|
pub image_rect_scaled: Option<[i64; 4]>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct EngineTypeFamilyEntry {
|
pub struct EngineTypeFamilyEntry {
|
||||||
pub canonical_stem: String,
|
pub canonical_stem: String,
|
||||||
|
|
@ -136,7 +149,7 @@ pub struct EngineTypeFamilyEntry {
|
||||||
pub has_matched_locomotive_pair: bool,
|
pub has_matched_locomotive_pair: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct EngineTypesInspectionReport {
|
pub struct EngineTypesInspectionReport {
|
||||||
pub source_root: String,
|
pub source_root: String,
|
||||||
pub side_view_imb_pk4_path: Option<String>,
|
pub side_view_imb_pk4_path: Option<String>,
|
||||||
|
|
@ -150,6 +163,7 @@ pub struct EngineTypesInspectionReport {
|
||||||
pub unmatched_lco_file_count: usize,
|
pub unmatched_lco_file_count: usize,
|
||||||
pub unmatched_cgo_file_count: usize,
|
pub unmatched_cgo_file_count: usize,
|
||||||
pub unmatched_cct_file_count: usize,
|
pub unmatched_cct_file_count: usize,
|
||||||
|
pub car_side_view_resource_profile_summaries: BTreeMap<String, EngineTypeImbProfileSummary>,
|
||||||
pub car_side_view_resource_counts: BTreeMap<String, usize>,
|
pub car_side_view_resource_counts: BTreeMap<String, usize>,
|
||||||
pub car_side_view_resource_pk4_match_count: usize,
|
pub car_side_view_resource_pk4_match_count: usize,
|
||||||
pub car_side_view_resource_pk4_missing_count: usize,
|
pub car_side_view_resource_pk4_missing_count: usize,
|
||||||
|
|
@ -160,6 +174,9 @@ pub struct EngineTypesInspectionReport {
|
||||||
pub internal_ne_profile_pk4_missing_count: usize,
|
pub internal_ne_profile_pk4_missing_count: usize,
|
||||||
pub locomotive_pair_internal_ne_profile_pk4_match_count: usize,
|
pub locomotive_pair_internal_ne_profile_pk4_match_count: usize,
|
||||||
pub locomotive_pair_internal_ne_profile_pk4_missing_count: usize,
|
pub locomotive_pair_internal_ne_profile_pk4_missing_count: usize,
|
||||||
|
pub internal_ne_profile_texture_size_counts: BTreeMap<String, usize>,
|
||||||
|
pub internal_ne_profile_horizontal_scale_modifier_counts: BTreeMap<String, usize>,
|
||||||
|
pub internal_ne_profile_max_percent_of_interface_vram_counts: BTreeMap<String, usize>,
|
||||||
pub lco_companion_stem_counts: BTreeMap<String, usize>,
|
pub lco_companion_stem_counts: BTreeMap<String, usize>,
|
||||||
pub lco_body_type_label_counts: BTreeMap<String, usize>,
|
pub lco_body_type_label_counts: BTreeMap<String, usize>,
|
||||||
pub lco_low_cardinality_lane_counts: BTreeMap<String, BTreeMap<String, usize>>,
|
pub lco_low_cardinality_lane_counts: BTreeMap<String, BTreeMap<String, usize>>,
|
||||||
|
|
@ -310,7 +327,7 @@ pub fn inspect_engine_types_dir(
|
||||||
let mut lco_reports = BTreeMap::<String, EngineTypeLcoInspectionReport>::new();
|
let mut lco_reports = BTreeMap::<String, EngineTypeLcoInspectionReport>::new();
|
||||||
let mut cgo_reports = BTreeMap::<String, EngineTypeCgoInspectionReport>::new();
|
let mut cgo_reports = BTreeMap::<String, EngineTypeCgoInspectionReport>::new();
|
||||||
let mut cct_reports = BTreeMap::<String, EngineTypeCctInspectionReport>::new();
|
let mut cct_reports = BTreeMap::<String, EngineTypeCctInspectionReport>::new();
|
||||||
let (side_view_imb_pk4_path, side_view_imb_entry_names) = load_side_view_imb_pk4_lookup(path)?;
|
let side_view_imb_pk4_lookup = load_side_view_imb_pk4_lookup(path)?;
|
||||||
|
|
||||||
for entry in fs::read_dir(path)? {
|
for entry in fs::read_dir(path)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
|
|
@ -363,7 +380,9 @@ pub fn inspect_engine_types_dir(
|
||||||
&car_reports,
|
&car_reports,
|
||||||
&lco_reports,
|
&lco_reports,
|
||||||
&cct_reports,
|
&cct_reports,
|
||||||
side_view_imb_entry_names.as_ref(),
|
side_view_imb_pk4_lookup
|
||||||
|
.as_ref()
|
||||||
|
.map(|lookup| &lookup.entry_names),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
@ -376,6 +395,10 @@ pub fn inspect_engine_types_dir(
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|family| family.side_view_resource.as_deref()),
|
.filter_map(|family| family.side_view_resource.as_deref()),
|
||||||
);
|
);
|
||||||
|
let car_side_view_resource_profile_summaries = side_view_imb_pk4_lookup
|
||||||
|
.as_ref()
|
||||||
|
.map(|lookup| lookup.car_side_view_profile_summaries.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
let car_side_view_resource_pk4_match_count = family_entries
|
let car_side_view_resource_pk4_match_count = family_entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|family| family.side_view_resource_found_in_pk4 == Some(true))
|
.filter(|family| family.side_view_resource_found_in_pk4 == Some(true))
|
||||||
|
|
@ -427,6 +450,26 @@ pub fn inspect_engine_types_dir(
|
||||||
&& family.internal_ne_profile_found_in_pk4 == Some(false)
|
&& family.internal_ne_profile_found_in_pk4 == Some(false)
|
||||||
})
|
})
|
||||||
.count();
|
.count();
|
||||||
|
let internal_ne_profile_texture_size_counts = side_view_imb_pk4_lookup
|
||||||
|
.as_ref()
|
||||||
|
.map(|lookup| lookup.internal_ne_profile_texture_size_counts.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let internal_ne_profile_horizontal_scale_modifier_counts = side_view_imb_pk4_lookup
|
||||||
|
.as_ref()
|
||||||
|
.map(|lookup| {
|
||||||
|
lookup
|
||||||
|
.internal_ne_profile_horizontal_scale_modifier_counts
|
||||||
|
.clone()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let internal_ne_profile_max_percent_of_interface_vram_counts = side_view_imb_pk4_lookup
|
||||||
|
.as_ref()
|
||||||
|
.map(|lookup| {
|
||||||
|
lookup
|
||||||
|
.internal_ne_profile_max_percent_of_interface_vram_counts
|
||||||
|
.clone()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
let lco_body_type_label_counts = count_named_values(
|
let lco_body_type_label_counts = count_named_values(
|
||||||
family_entries
|
family_entries
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -458,7 +501,9 @@ pub fn inspect_engine_types_dir(
|
||||||
|
|
||||||
Ok(EngineTypesInspectionReport {
|
Ok(EngineTypesInspectionReport {
|
||||||
source_root: path.display().to_string(),
|
source_root: path.display().to_string(),
|
||||||
side_view_imb_pk4_path,
|
side_view_imb_pk4_path: side_view_imb_pk4_lookup
|
||||||
|
.as_ref()
|
||||||
|
.map(|lookup| lookup.path.clone()),
|
||||||
family_count: family_entries.len(),
|
family_count: family_entries.len(),
|
||||||
car_file_count: family_entries
|
car_file_count: family_entries
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -497,6 +542,7 @@ pub fn inspect_engine_types_dir(
|
||||||
entry.cct_file.is_some() && !(entry.car_file.is_some() || entry.lco_file.is_some())
|
entry.cct_file.is_some() && !(entry.car_file.is_some() || entry.lco_file.is_some())
|
||||||
})
|
})
|
||||||
.count(),
|
.count(),
|
||||||
|
car_side_view_resource_profile_summaries,
|
||||||
car_side_view_resource_counts,
|
car_side_view_resource_counts,
|
||||||
car_side_view_resource_pk4_match_count,
|
car_side_view_resource_pk4_match_count,
|
||||||
car_side_view_resource_pk4_missing_count,
|
car_side_view_resource_pk4_missing_count,
|
||||||
|
|
@ -507,6 +553,9 @@ pub fn inspect_engine_types_dir(
|
||||||
internal_ne_profile_pk4_missing_count,
|
internal_ne_profile_pk4_missing_count,
|
||||||
locomotive_pair_internal_ne_profile_pk4_match_count,
|
locomotive_pair_internal_ne_profile_pk4_match_count,
|
||||||
locomotive_pair_internal_ne_profile_pk4_missing_count,
|
locomotive_pair_internal_ne_profile_pk4_missing_count,
|
||||||
|
internal_ne_profile_texture_size_counts,
|
||||||
|
internal_ne_profile_horizontal_scale_modifier_counts,
|
||||||
|
internal_ne_profile_max_percent_of_interface_vram_counts,
|
||||||
lco_companion_stem_counts,
|
lco_companion_stem_counts,
|
||||||
lco_body_type_label_counts,
|
lco_body_type_label_counts,
|
||||||
lco_low_cardinality_lane_counts,
|
lco_low_cardinality_lane_counts,
|
||||||
|
|
@ -529,6 +578,15 @@ struct EngineTypeFamilyBuilder {
|
||||||
cct_file: Option<String>,
|
cct_file: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SideViewImbPk4Lookup {
|
||||||
|
path: String,
|
||||||
|
entry_names: BTreeSet<String>,
|
||||||
|
car_side_view_profile_summaries: BTreeMap<String, EngineTypeImbProfileSummary>,
|
||||||
|
internal_ne_profile_texture_size_counts: BTreeMap<String, usize>,
|
||||||
|
internal_ne_profile_horizontal_scale_modifier_counts: BTreeMap<String, usize>,
|
||||||
|
internal_ne_profile_max_percent_of_interface_vram_counts: BTreeMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
fn build_family_entry(
|
fn build_family_entry(
|
||||||
family: &EngineTypeFamilyBuilder,
|
family: &EngineTypeFamilyBuilder,
|
||||||
car_reports: &BTreeMap<String, EngineTypeCarInspectionReport>,
|
car_reports: &BTreeMap<String, EngineTypeCarInspectionReport>,
|
||||||
|
|
@ -581,22 +639,65 @@ fn build_family_entry(
|
||||||
|
|
||||||
fn load_side_view_imb_pk4_lookup(
|
fn load_side_view_imb_pk4_lookup(
|
||||||
engine_types_dir: &Path,
|
engine_types_dir: &Path,
|
||||||
) -> Result<(Option<String>, Option<BTreeSet<String>>), Box<dyn std::error::Error>> {
|
) -> Result<Option<SideViewImbPk4Lookup>, Box<dyn std::error::Error>> {
|
||||||
let Some(data_dir) = engine_types_dir.parent() else {
|
let Some(data_dir) = engine_types_dir.parent() else {
|
||||||
return Ok((None, None));
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let pk4_path = find_case_insensitive_file(&data_dir.join("2D"), "rt3_2imb.pk4");
|
let pk4_path = find_case_insensitive_file(&data_dir.join("2D"), "rt3_2imb.pk4");
|
||||||
let Some(pk4_path) = pk4_path else {
|
let Some(pk4_path) = pk4_path else {
|
||||||
return Ok((None, None));
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let bytes = fs::read(&pk4_path)?;
|
||||||
let inspection = inspect_pk4_file(&pk4_path)?;
|
let inspection = inspect_pk4_file(&pk4_path)?;
|
||||||
let entry_names = inspection
|
let mut entry_names = BTreeSet::new();
|
||||||
.entries
|
let mut car_side_view_profile_summaries = BTreeMap::new();
|
||||||
.into_iter()
|
let mut internal_ne_profile_texture_size_counts = BTreeMap::new();
|
||||||
.map(|entry| entry.name)
|
let mut internal_ne_profile_horizontal_scale_modifier_counts = BTreeMap::new();
|
||||||
.collect::<BTreeSet<_>>();
|
let mut internal_ne_profile_max_percent_of_interface_vram_counts = BTreeMap::new();
|
||||||
Ok((Some(pk4_path.display().to_string()), Some(entry_names)))
|
|
||||||
|
for entry in inspection.entries {
|
||||||
|
entry_names.insert(entry.name.clone());
|
||||||
|
let is_car_side_view = entry.name.starts_with("CarSideView_");
|
||||||
|
let is_internal_ne = entry.name.ends_with("_NE.imb");
|
||||||
|
if !(is_car_side_view || is_internal_ne) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = &bytes[entry.payload_absolute_offset..entry.payload_end_offset];
|
||||||
|
let imb_profile = summarize_imb_profile(&inspect_imb_bytes(payload)?);
|
||||||
|
if is_car_side_view {
|
||||||
|
car_side_view_profile_summaries.insert(entry.name.clone(), imb_profile.clone());
|
||||||
|
}
|
||||||
|
if is_internal_ne {
|
||||||
|
if let (Some(width), Some(height)) =
|
||||||
|
(imb_profile.texture_width, imb_profile.texture_height)
|
||||||
|
{
|
||||||
|
*internal_ne_profile_texture_size_counts
|
||||||
|
.entry(format!("{width}x{height}"))
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
if let Some(horizontal_scale_modifier) = imb_profile.horizontal_scale_modifier {
|
||||||
|
*internal_ne_profile_horizontal_scale_modifier_counts
|
||||||
|
.entry(format!("{horizontal_scale_modifier:.6}"))
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
if let Some(max_percent) = imb_profile.max_percent_of_interface_vram {
|
||||||
|
*internal_ne_profile_max_percent_of_interface_vram_counts
|
||||||
|
.entry(format!("{max_percent:.6}"))
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(SideViewImbPk4Lookup {
|
||||||
|
path: pk4_path.display().to_string(),
|
||||||
|
entry_names,
|
||||||
|
car_side_view_profile_summaries,
|
||||||
|
internal_ne_profile_texture_size_counts,
|
||||||
|
internal_ne_profile_horizontal_scale_modifier_counts,
|
||||||
|
internal_ne_profile_max_percent_of_interface_vram_counts,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_case_insensitive_file(dir: &Path, expected_name: &str) -> Option<PathBuf> {
|
fn find_case_insensitive_file(dir: &Path, expected_name: &str) -> Option<PathBuf> {
|
||||||
|
|
@ -614,6 +715,19 @@ fn find_case_insensitive_file(dir: &Path, expected_name: &str) -> Option<PathBuf
|
||||||
.map(|entry| entry.path())
|
.map(|entry| entry.path())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn summarize_imb_profile(report: &ImbInspectionReport) -> EngineTypeImbProfileSummary {
|
||||||
|
EngineTypeImbProfileSummary {
|
||||||
|
tga_name: report.tga_name.clone(),
|
||||||
|
texture_width: report.texture_width,
|
||||||
|
texture_height: report.texture_height,
|
||||||
|
target_screen_width: report.target_screen_width,
|
||||||
|
target_screen_height: report.target_screen_height,
|
||||||
|
horizontal_scale_modifier: report.horizontal_scale_modifier,
|
||||||
|
max_percent_of_interface_vram: report.max_percent_of_interface_vram,
|
||||||
|
image_rect_scaled: report.image_rect_scaled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_locomotive_display_census(
|
fn build_locomotive_display_census(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
families: &[EngineTypeFamilyEntry],
|
families: &[EngineTypeFamilyEntry],
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ This file is the short active queue for the current runtime and reverse-engineer
|
||||||
The early `.lco` lane block is now partially partitioned too: only offsets `0x20`, `0x34`, `0x38`, `0x3c`, `0x44`, `0x48`, and `0x54` behave like low-cardinality buckets, while the other early lanes still look high-variance.
|
The early `.lco` lane block is now partially partitioned too: only offsets `0x20`, `0x34`, `0x38`, `0x3c`, `0x44`, `0x48`, and `0x54` behave like low-cardinality buckets, while the other early lanes still look high-variance.
|
||||||
The side-view resource path is now grounded into `Data/2D/rt3_2IMB.PK4`, and the `.imb` parser now decodes shipped comment-suffixed numeric rows plus `_NE` profile fields such as `HorizontalScaleModifier` and `ImageWHScaled`.
|
The side-view resource path is now grounded into `Data/2D/rt3_2IMB.PK4`, and the `.imb` parser now decodes shipped comment-suffixed numeric rows plus `_NE` profile fields such as `HorizontalScaleModifier` and `ImageWHScaled`.
|
||||||
The checked PK4 linkage split is now explicit too: `132 / 145` side-view resource names resolve directly, but the remaining `13` are the missing `CarSideView_3.imb` cohort and that hole exists in both checked installs, while `43 / 145` derived `{internal_stem}_NE.imb` names resolve and all of those hits belong to matched locomotive pairs.
|
The checked PK4 linkage split is now explicit too: `132 / 145` side-view resource names resolve directly, but the remaining `13` are the missing `CarSideView_3.imb` cohort and that hole exists in both checked installs, while `43 / 145` derived `{internal_stem}_NE.imb` names resolve and all of those hits belong to matched locomotive pairs.
|
||||||
|
The packaged profile metadata is stable enough to summarize: `CarSideView_1` is `512x512` at `0.04` VRAM, `CarSideView_2` is `512x256` at `0.02`, and every packaged `_NE` profile is `512x128` with `HorizontalScaleModifier = 0.75` and `MaxPercentOfInterfaceVRAM = 0.09`.
|
||||||
The next honest static work is to keep promoting those fixed lanes into stable parser fields, explain the five remaining distinct auxiliary-stem cases, and decide how far the `.cgo` ladders plus the low-cardinality `.lco` lanes can be grounded without overclaiming semantics.
|
The next honest static work is to keep promoting those fixed lanes into stable parser fields, explain the five remaining distinct auxiliary-stem cases, and decide how far the `.cgo` ladders plus the low-cardinality `.lco` lanes can be grounded without overclaiming semantics.
|
||||||
Preserved checked parser detail now lives in [EngineTypes parser semantics](rehost-queue/engine-types-parser-semantics-2026-04-21.md).
|
Preserved checked parser detail now lives in [EngineTypes parser semantics](rehost-queue/engine-types-parser-semantics-2026-04-21.md).
|
||||||
Preserved checked format inventory detail now lives in [RT3 format inventory](rehost-queue/format-inventory-2026-04-21.md).
|
Preserved checked format inventory detail now lives in [RT3 format inventory](rehost-queue/format-inventory-2026-04-21.md).
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ first `.car` / `.lco` / `.cgo` / `.cct` inspector pass landed.
|
||||||
- no freight-car or tender family currently resolves to a packaged `{internal_stem}_NE.imb`
|
- no freight-car or tender family currently resolves to a packaged `{internal_stem}_NE.imb`
|
||||||
- That `CarSideView_3.imb` hole is now confirmed across both checked installs (`rt3` and
|
- That `CarSideView_3.imb` hole is now confirmed across both checked installs (`rt3` and
|
||||||
`rt3_105`): neither shipped `rt3_2IMB.PK4` contains a `CarSideView_3.imb` entry.
|
`rt3_105`): neither shipped `rt3_2IMB.PK4` contains a `CarSideView_3.imb` entry.
|
||||||
|
- The packaged profile metadata is now bounded too:
|
||||||
|
- `CarSideView_1.imb`: `512x512`, `MaxPercentOfInterfaceVRAM = 0.04`
|
||||||
|
- `CarSideView_2.imb`: `512x256`, `MaxPercentOfInterfaceVRAM = 0.02`
|
||||||
|
- every packaged `_NE.imb` profile: `512x128`, `HorizontalScaleModifier = 0.75`,
|
||||||
|
`MaxPercentOfInterfaceVRAM = 0.09`, and `ImageWHScaled` present
|
||||||
|
|
||||||
## What The Current Parser Now Owns
|
## What The Current Parser Now Owns
|
||||||
|
|
||||||
|
|
@ -94,6 +99,9 @@ first `.car` / `.lco` / `.cgo` / `.cct` inspector pass landed.
|
||||||
- comment-aware typed numeric extraction for shipped profile rows
|
- comment-aware typed numeric extraction for shipped profile rows
|
||||||
- `HorizontalScaleModifier`
|
- `HorizontalScaleModifier`
|
||||||
- `ImageWHScaled`
|
- `ImageWHScaled`
|
||||||
|
- `rt3_2IMB.PK4` link surface
|
||||||
|
- packaged side-view profile summaries for `CarSideView_*`
|
||||||
|
- packaged `_NE.imb` texture-size and scalar-family counts
|
||||||
|
|
||||||
## Remaining Static Questions
|
## Remaining Static Questions
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue