Shapeshifted item use is refused locally; a helm reads its race prefix (#2924)
Two gaps against 1.12.1: - A shapeshifted player's item use went to the server and could ask to bind the item first. In 1.12.1 `0x5d9105` refuses it when the form is no stance (`0x60e9f0`) and its `SpellShapeshiftForm` flags lack 0x8, or carry 0x40 and the item is not equippable. It shows ERR_NO_ITEMS_WHILE_SHAPESHIFTED and sends nothing, before the bind ask. So cat, bear, travel and aquatic form, and ghost wolf, allow only equippable items, Tree of Life none, and stances all. benilla now refuses the same way. - A helm's model took its race prefix from an eight-entry table clamped to Troll. `0x4799a0` reads the `ChrRaces` row's client prefix and attaches no helm for a race with no row. benilla now does the same, so a goblin's helm loads `_Gb`. Tests: the route order, the stock forms and the stock race prefixes. Fixes #2477 Fixes #611 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -974,9 +974,13 @@ fn setup_entities(
|
||||
match load_item_display_catalog(&mut chain) {
|
||||
Ok(catalog) => {
|
||||
info!("item display catalog: {} entries", catalog.len());
|
||||
let races = benilla_formats::load_chr_races(&mut chain)
|
||||
.map_err(|e| warn!("ChrRaces.dbc unavailable, no helm shows: {e:#}"))
|
||||
.unwrap_or_default();
|
||||
commands.insert_resource(ItemDisplays {
|
||||
catalog,
|
||||
models: HashMap::new(),
|
||||
races,
|
||||
});
|
||||
}
|
||||
Err(e) => warn!("item displays unavailable, units hold nothing: {e:#}"),
|
||||
|
||||
@@ -58,6 +58,8 @@ pub(crate) struct ItemDisplays {
|
||||
pub(crate) catalog: ItemDisplayCatalog,
|
||||
/// Keyed by model kind too: a helm display has a file per race and sex, a shoulder a pair.
|
||||
pub(super) models: HashMap<(u32, ItemModelKind), DisplayModel>,
|
||||
/// `ChrRaces.dbc`, whose client prefix names a helm's model per race (`0x479a21`).
|
||||
pub(crate) races: benilla_formats::ChrRaces,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -66,6 +68,7 @@ impl ItemDisplays {
|
||||
ItemDisplays {
|
||||
catalog,
|
||||
models: HashMap::new(),
|
||||
races: benilla_formats::ChrRaces::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,6 +375,21 @@ pub(crate) const ATTACH_SLOT_NAMES: [&str; ATTACH_SLOTS] = [
|
||||
/// (`0x47a200`) and the ranged and ammo builder pass the display's visual.
|
||||
const NO_GLOW: i32 = 0;
|
||||
|
||||
/// A helm display's model file for a race and sex: `<stem>_<prefix><M|F>.m2`, the prefix the
|
||||
/// `ChrRaces` row's client prefix (`+0x18`, `0x479a21`, formatted at `0x479a41`). A race with no
|
||||
/// row has no model, and `0x4799a0` attaches no helm (`0x4799cc`, `0x4799d8`, `0x4799e9`).
|
||||
fn helm_model_name(
|
||||
model: &str,
|
||||
races: &benilla_formats::ChrRaces,
|
||||
race: u8,
|
||||
sex: u8,
|
||||
) -> Option<String> {
|
||||
let prefix = races.client_prefix(u32::from(race))?;
|
||||
let letter = if sex == 1 { 'F' } else { 'M' };
|
||||
let stem = model.strip_suffix(".m2").unwrap_or(model);
|
||||
Some(format!("{stem}_{prefix}{letter}.m2"))
|
||||
}
|
||||
|
||||
/// Ensure `display` has a [`DisplayModel`] for `kind`; its texture is the BLP the display row
|
||||
/// names, never derived from the model name.
|
||||
pub(in crate::entities) fn ensure_item_model(
|
||||
@@ -407,12 +425,13 @@ pub(in crate::entities) fn ensure_item_model(
|
||||
Some(d) if d.model[col].is_some() => {
|
||||
let mut model = d.model[col].clone().unwrap();
|
||||
if let ItemModelKind::Helm { race, sex } = kind {
|
||||
// Race 1-8 is Hu Or Dw Ni Sc Ta Gn Tr, sex M or F.
|
||||
const RACE_PREFIX: [&str; 8] = ["Hu", "Or", "Dw", "Ni", "Sc", "Ta", "Gn", "Tr"];
|
||||
let prefix = RACE_PREFIX[(race.clamp(1, 8) - 1) as usize];
|
||||
let letter = if sex == 1 { 'F' } else { 'M' };
|
||||
let stem = model.strip_suffix(".m2").unwrap_or(&model).to_string();
|
||||
model = format!("{stem}_{prefix}{letter}.m2");
|
||||
// `0x4799a0` names the model by the `ChrRaces` row's client prefix, and a race
|
||||
// with no row attaches no helm.
|
||||
let Some(named) = helm_model_name(&model, &held.races, race, sex) else {
|
||||
held.models.insert((display, kind), super::empty_display());
|
||||
return;
|
||||
};
|
||||
model = named;
|
||||
}
|
||||
let disp_id = display;
|
||||
debug!(
|
||||
@@ -431,3 +450,49 @@ pub(in crate::entities) fn ensure_item_model(
|
||||
};
|
||||
held.models.insert((display, kind), dm);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod helm_model_tests {
|
||||
use super::helm_model_name;
|
||||
use benilla_formats::ChrRaces;
|
||||
|
||||
fn races() -> ChrRaces {
|
||||
ChrRaces::from_rows([
|
||||
(1, "Human", "Human"),
|
||||
(8, "Troll", "Troll"),
|
||||
(9, "Goblin", "Goblin"),
|
||||
])
|
||||
.with_client_prefix(1, "Hu")
|
||||
.with_client_prefix(8, "Tr")
|
||||
.with_client_prefix(9, "Gb")
|
||||
}
|
||||
|
||||
/// The prefix is the race row's, so race 9 gets its own model and not the Troll one a clamp to
|
||||
/// eight gave it; a race with no row, 0 included, has none (`0x4799cc`-`0x4799e9`).
|
||||
#[test]
|
||||
fn a_helm_takes_its_races_prefix_and_a_race_without_a_row_has_none() {
|
||||
let races = races();
|
||||
let name = |race, sex| helm_model_name("Helm_Plate.m2", &races, race, sex);
|
||||
assert_eq!(name(1, 0).as_deref(), Some("Helm_Plate_HuM.m2"));
|
||||
assert_eq!(name(8, 1).as_deref(), Some("Helm_Plate_TrF.m2"));
|
||||
assert_eq!(name(9, 0).as_deref(), Some("Helm_Plate_GbM.m2"));
|
||||
assert_eq!(name(10, 0), None);
|
||||
assert_eq!(name(0, 0), None);
|
||||
}
|
||||
|
||||
/// The shipped `ChrRaces.dbc` through the same read: the eight playable prefixes in race
|
||||
/// order, `Gb` for the goblin, nothing past the nine rows.
|
||||
#[test]
|
||||
fn the_shipped_races_name_their_helm_models() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let races = benilla_formats::load_chr_races(&mut chain).expect("ChrRaces.dbc");
|
||||
let prefixes: Vec<_> = (1..=9).map(|r| races.client_prefix(r)).collect();
|
||||
assert_eq!(
|
||||
prefixes,
|
||||
["Hu", "Or", "Dw", "Ni", "Sc", "Ta", "Gn", "Tr", "Gb"].map(Some)
|
||||
);
|
||||
assert_eq!(races.client_prefix(0), None);
|
||||
assert_eq!(races.client_prefix(10), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,16 +648,38 @@ pub(crate) enum ItemUseRoute {
|
||||
/// `0x5d926d`) locally and sends nothing. Only the worn hand the disarm hides (slot 15 or 16)
|
||||
/// is refused, and the rung sits above the bind confirm (`0x5d91d6`) and the cast tail.
|
||||
CantUseDisarmed,
|
||||
/// Rung 16: a shapeshifted player whose form row bars the item is refused
|
||||
/// `ERR_NO_ITEMS_WHILE_SHAPESHIFTED` (`0x15e`, `0x5d914d`) and nothing is sent. It sits below
|
||||
/// the disarmed refusal and above the toggle scan, the bind ask and the cast tail.
|
||||
NoItemsShapeshifted,
|
||||
}
|
||||
|
||||
/// `0x5d9105`-`0x5d914d`: whether the player's form refuses an item. A form is shapeshifted
|
||||
/// (`IsShapeShifted`, `0x60e9f0`) when the byte is nonzero and its `SpellShapeshiftForm` row is
|
||||
/// not a stance (`flags1` bit 0); the row (`+0x2c`) then passes the item with bit `0x8`, or with
|
||||
/// bit `0x40` for an equippable one (`0x5d9ff0`, a nonzero inventory type). A form with no row
|
||||
/// passes (`0x5d9134`).
|
||||
pub(crate) fn form_refuses_item(
|
||||
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
|
||||
form: u8,
|
||||
equippable: bool,
|
||||
) -> bool {
|
||||
form != 0
|
||||
&& forms
|
||||
.get(&u32::from(form))
|
||||
.is_some_and(|row| !row.is_stance() && !row.allows_items(equippable))
|
||||
}
|
||||
|
||||
/// [`ItemUseRoute`]'s decision, in the reference's order: quest offer (`0x5d8dcc`), charter
|
||||
/// (`0x5d8f95`), disarmed refusal, toggle scan (`0x5d9157`), cast tail (`0x5d9249`). The toggle
|
||||
/// predicate and the disarmed hand are caster state, passed in; a `None` guid cannot address a
|
||||
/// (`0x5d8f95`), disarmed refusal, form refusal (`0x5d9105`), toggle scan (`0x5d9157`), cast tail
|
||||
/// (`0x5d9249`). The toggle predicate, the disarmed hand and the form refusal are caster state,
|
||||
/// passed in; a `None` guid cannot address a
|
||||
/// questgiver, so it falls through to the ordinary path.
|
||||
pub(crate) fn item_use_route(
|
||||
it: ItemUse,
|
||||
aura_cancels: impl Fn(u32) -> bool,
|
||||
disarmed_hand: Option<u8>,
|
||||
form_refuses: bool,
|
||||
) -> ItemUseRoute {
|
||||
if let Some(npc) = it.guid.filter(|_| it.start_quest != 0) {
|
||||
return ItemUseRoute::QuestOffer {
|
||||
@@ -674,6 +696,9 @@ pub(crate) fn item_use_route(
|
||||
if disarmed_hand.is_some() && it.bag_index == PLAYER_ARRAY && Some(it.slot) == disarmed_hand {
|
||||
return ItemUseRoute::CantUseDisarmed;
|
||||
}
|
||||
if form_refuses {
|
||||
return ItemUseRoute::NoItemsShapeshifted;
|
||||
}
|
||||
match it.use_spell {
|
||||
Some(spell) if aura_cancels(spell) => ItemUseRoute::ToggleCancel(spell),
|
||||
Some(spell) => ItemUseRoute::Cast(spell),
|
||||
@@ -742,7 +767,30 @@ pub(crate) fn send_item_use(
|
||||
let disarmed_hand = ctx.rel.self_store.and_then(|store| {
|
||||
crate::items::disarmed_equipment_slot(store, &ladder.objects, &ladder.items)
|
||||
});
|
||||
match item_use_route(it, aura_cancels, disarmed_hand) {
|
||||
// The form test (`0x5d9105`) reads the template's inventory type (`0x5d9ff0`).
|
||||
let form_refuses = {
|
||||
let form = ctx.rel.self_store.map_or(0, |s| s.0.unit_shapeshift_form());
|
||||
let equippable = ladder
|
||||
.items
|
||||
.template(
|
||||
it.entry,
|
||||
it.guid.unwrap_or(0),
|
||||
crate::query_cache::Ask::Read,
|
||||
)
|
||||
.is_some_and(|t| t.inventory_type != 0);
|
||||
ladder
|
||||
.spells
|
||||
.as_ref()
|
||||
.is_some_and(|s| form_refuses_item(&s.forms, form, equippable))
|
||||
};
|
||||
match item_use_route(it, aura_cancels, disarmed_hand, form_refuses) {
|
||||
ItemUseRoute::NoItemsShapeshifted => {
|
||||
debug!("ui_items: item use refused — shapeshifted");
|
||||
ui_errors.push(crate::ui_action::UiError::key(
|
||||
"ERR_NO_ITEMS_WHILE_SHAPESHIFTED",
|
||||
));
|
||||
false
|
||||
}
|
||||
ItemUseRoute::CantUseDisarmed => {
|
||||
debug!(
|
||||
"ui_items: item use refused — that hand is disarmed (slot {})",
|
||||
@@ -1108,12 +1156,12 @@ mod tests {
|
||||
let never = |_: u32| false;
|
||||
|
||||
assert_eq!(
|
||||
item_use_route(worn(15), never, Some(15)),
|
||||
item_use_route(worn(15), never, Some(15), false),
|
||||
ItemUseRoute::CantUseDisarmed
|
||||
);
|
||||
// A disarmed dual-wielder's other hand is not hidden.
|
||||
assert_eq!(
|
||||
item_use_route(worn(16), never, Some(15)),
|
||||
item_use_route(worn(16), never, Some(15), false),
|
||||
ItemUseRoute::Cast(8690)
|
||||
);
|
||||
// The same item in a bag is not worn, so it is never this refusal.
|
||||
@@ -1124,18 +1172,19 @@ mod tests {
|
||||
..worn(15)
|
||||
},
|
||||
never,
|
||||
Some(15)
|
||||
Some(15),
|
||||
false
|
||||
),
|
||||
ItemUseRoute::Cast(8690)
|
||||
);
|
||||
// Flag down: nothing is hidden.
|
||||
assert_eq!(
|
||||
item_use_route(worn(15), never, None),
|
||||
item_use_route(worn(15), never, None, false),
|
||||
ItemUseRoute::Cast(8690)
|
||||
);
|
||||
// The off hand hidden: the ladder's empty or non-weapon main-hand rung.
|
||||
assert_eq!(
|
||||
item_use_route(worn(16), never, Some(16)),
|
||||
item_use_route(worn(16), never, Some(16), false),
|
||||
ItemUseRoute::CantUseDisarmed
|
||||
);
|
||||
}
|
||||
@@ -1158,25 +1207,25 @@ mod tests {
|
||||
};
|
||||
let never = |_| false;
|
||||
assert_eq!(
|
||||
item_use_route(it(Some(letter), 373, None), never, None),
|
||||
item_use_route(it(Some(letter), 373, None), never, None, false),
|
||||
ItemUseRoute::QuestOffer {
|
||||
npc: letter,
|
||||
quest: 373
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
item_use_route(it(Some(letter), 0, Some(8690)), never, None),
|
||||
item_use_route(it(Some(letter), 0, Some(8690)), never, None, false),
|
||||
ItemUseRoute::Cast(8690),
|
||||
"a hearthstone takes the cast tail"
|
||||
);
|
||||
assert_eq!(
|
||||
item_use_route(it(Some(letter), 0, None), never, None),
|
||||
item_use_route(it(Some(letter), 0, None), never, None, false),
|
||||
ItemUseRoute::Nothing,
|
||||
"no ON_USE block — the ref sends nothing"
|
||||
);
|
||||
// No resolved instance: nothing to address, so the ordinary path runs.
|
||||
assert_eq!(
|
||||
item_use_route(it(None, 373, Some(8690)), never, None),
|
||||
item_use_route(it(None, 373, Some(8690)), never, None, false),
|
||||
ItemUseRoute::Cast(8690)
|
||||
);
|
||||
}
|
||||
@@ -1199,22 +1248,22 @@ mod tests {
|
||||
};
|
||||
let never = |_| false;
|
||||
assert_eq!(
|
||||
item_use_route(it(Some(charter), true, None), never, None),
|
||||
item_use_route(it(Some(charter), true, None), never, None, false),
|
||||
ItemUseRoute::ShowPetition { item: charter },
|
||||
"the charter opens its petition window"
|
||||
);
|
||||
// Without the flag, the same item is the reference's silent no-op.
|
||||
assert_eq!(
|
||||
item_use_route(it(Some(charter), false, None), never, None),
|
||||
item_use_route(it(Some(charter), false, None), never, None, false),
|
||||
ItemUseRoute::Nothing
|
||||
);
|
||||
// No resolved instance: it falls through, as the quest fork does.
|
||||
assert_eq!(
|
||||
item_use_route(it(None, true, None), never, None),
|
||||
item_use_route(it(None, true, None), never, None, false),
|
||||
ItemUseRoute::Nothing
|
||||
);
|
||||
assert_eq!(
|
||||
item_use_route(it(None, true, Some(8690)), never, None),
|
||||
item_use_route(it(None, true, Some(8690)), never, None, false),
|
||||
ItemUseRoute::Cast(8690)
|
||||
);
|
||||
}
|
||||
@@ -1237,18 +1286,18 @@ mod tests {
|
||||
};
|
||||
let mounted = |spell: u32| spell == SUMMON_HORSE;
|
||||
assert_eq!(
|
||||
item_use_route(it(0, Some(SUMMON_HORSE)), mounted, None),
|
||||
item_use_route(it(0, Some(SUMMON_HORSE)), mounted, None, false),
|
||||
ItemUseRoute::ToggleCancel(SUMMON_HORSE),
|
||||
"mounted: the click dismounts — CMSG_CANCEL_AURA, no CMSG_USE_ITEM",
|
||||
);
|
||||
assert_eq!(
|
||||
item_use_route(it(0, Some(SUMMON_HORSE)), |_| false, None),
|
||||
item_use_route(it(0, Some(SUMMON_HORSE)), |_| false, None, false),
|
||||
ItemUseRoute::Cast(SUMMON_HORSE),
|
||||
"not mounted: the very same click casts",
|
||||
);
|
||||
// Order: the quest offer forks at `0x5d8dcc`, above the toggle scan at `0x5d9157`.
|
||||
assert_eq!(
|
||||
item_use_route(it(373, Some(SUMMON_HORSE)), mounted, None),
|
||||
item_use_route(it(373, Some(SUMMON_HORSE)), mounted, None, false),
|
||||
ItemUseRoute::QuestOffer {
|
||||
npc: 0x4000_0000_0000_0BAD,
|
||||
quest: 373
|
||||
@@ -1256,7 +1305,7 @@ mod tests {
|
||||
);
|
||||
// An item with no ON_USE block has nothing to cancel, whatever the predicate says.
|
||||
assert_eq!(
|
||||
item_use_route(it(0, None), |_| true, None),
|
||||
item_use_route(it(0, None), |_| true, None, false),
|
||||
ItemUseRoute::Nothing
|
||||
);
|
||||
}
|
||||
@@ -1371,6 +1420,93 @@ mod tests {
|
||||
assert_eq!(wire_pos(EQUIPMENT_BAG, 63), None, "the doll-space gap");
|
||||
assert_eq!(wire_pos(EQUIPMENT_BAG, 70), None, "past the bank bags");
|
||||
}
|
||||
/// The form refusal sits below the quest and charter arms and the disarmed refusal, and above
|
||||
/// the toggle scan and the cast (`0x5d9105`).
|
||||
#[test]
|
||||
fn the_form_refusal_ranks_between_the_disarm_and_the_cast() {
|
||||
let it = ItemUse {
|
||||
entry: 7,
|
||||
guid: Some(1),
|
||||
start_quest: 0,
|
||||
bag_index: 1,
|
||||
slot: 0,
|
||||
spell_index: 0,
|
||||
use_spell: Some(8690),
|
||||
on_object: None,
|
||||
is_charter: false,
|
||||
};
|
||||
let toggling = |_: u32| true;
|
||||
assert_eq!(
|
||||
item_use_route(it, toggling, None, true),
|
||||
ItemUseRoute::NoItemsShapeshifted
|
||||
);
|
||||
assert_eq!(
|
||||
item_use_route(
|
||||
ItemUse {
|
||||
start_quest: 373,
|
||||
..it
|
||||
},
|
||||
toggling,
|
||||
None,
|
||||
true
|
||||
),
|
||||
ItemUseRoute::QuestOffer { npc: 1, quest: 373 }
|
||||
);
|
||||
assert_eq!(
|
||||
item_use_route(
|
||||
ItemUse {
|
||||
is_charter: true,
|
||||
..it
|
||||
},
|
||||
toggling,
|
||||
None,
|
||||
true
|
||||
),
|
||||
ItemUseRoute::ShowPetition { item: 1 }
|
||||
);
|
||||
let worn = ItemUse {
|
||||
bag_index: super::PLAYER_ARRAY,
|
||||
slot: 15,
|
||||
..it
|
||||
};
|
||||
assert_eq!(
|
||||
item_use_route(worn, toggling, Some(15), true),
|
||||
ItemUseRoute::CantUseDisarmed
|
||||
);
|
||||
assert_eq!(
|
||||
item_use_route(it, toggling, None, false),
|
||||
ItemUseRoute::ToggleCancel(8690)
|
||||
);
|
||||
}
|
||||
|
||||
/// The stock forms through the gate (`0x5d9136`-`0x5d914d`), by name.
|
||||
#[test]
|
||||
fn the_shipped_forms_refuse_items_by_their_flags() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let forms =
|
||||
benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc");
|
||||
let refuses = |form: u8, equippable| super::form_refuses_item(&forms, form, equippable);
|
||||
// Cat, Travel, Aquatic, Bear, Dire Bear (flags 0x40 set) and Ghost Wolf: an equippable
|
||||
// item passes, any other is refused.
|
||||
for form in [1, 3, 4, 5, 8, 16] {
|
||||
assert!(refuses(form, false), "form {form}, a potion");
|
||||
assert!(!refuses(form, true), "form {form}, a trinket");
|
||||
}
|
||||
// Tree of Life (flags 0x10) and the unused rows, flags 0: every item is refused.
|
||||
for form in [2, 6, 7, 9, 10, 11, 12, 13, 14, 15, 20, 29, 32] {
|
||||
assert!(refuses(form, false) && refuses(form, true), "form {form}");
|
||||
}
|
||||
// The stances (flags bit 0, `IsShapeShifted` false): Battle, Defensive, Berserker,
|
||||
// Shadowform, Stealth and the rest pass everything.
|
||||
for form in [17, 18, 19, 28, 30, 31] {
|
||||
assert!(!refuses(form, false) && !refuses(form, true), "form {form}");
|
||||
}
|
||||
// No stock form is a shapeshift with flag 0x8, which would pass every item.
|
||||
assert!(forms.values().all(|f| f.is_stance() || f.flags & 0x8 == 0));
|
||||
assert!(!super::form_refuses_item(&forms, 0, false), "no form");
|
||||
assert!(!super::form_refuses_item(&forms, 200, false), "no row");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`find_item`]'s order, which is the reference walk's.
|
||||
|
||||
@@ -20,6 +20,10 @@ const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc";
|
||||
/// The shipped copy's column count, which `benilla-dbc` enforces as the reference loader does.
|
||||
const CHR_RACES_FIELDS: usize = 29;
|
||||
|
||||
/// Field 6, the client prefix (`+0x18`), the `%s` of a helm model's `<stem>_<prefix><sex>.m2`
|
||||
/// (`0x479a21`).
|
||||
const PREFIX_FIELD: usize = 0x18 / 4;
|
||||
|
||||
/// Field 15, the file string (`+0x3c`).
|
||||
const FILE_FIELD: usize = 0x3c / 4;
|
||||
|
||||
@@ -34,6 +38,8 @@ const NAME_FIELD: usize = 0x44 / 4;
|
||||
struct Row {
|
||||
name: String,
|
||||
file: String,
|
||||
/// The client prefix (`+0x18`), `Hu`, `Or`, ..., `Gb`.
|
||||
prefix: String,
|
||||
/// `CinematicSequenceID` (`+0x40`).
|
||||
intro: u32,
|
||||
}
|
||||
@@ -49,8 +55,16 @@ impl ChrRaces {
|
||||
rows.into_iter()
|
||||
.map(|(id, name, file)| {
|
||||
let (name, file) = (name.to_string(), file.to_string());
|
||||
let intro = 0;
|
||||
(id, Row { name, file, intro })
|
||||
let (intro, prefix) = (0, String::new());
|
||||
(
|
||||
id,
|
||||
Row {
|
||||
name,
|
||||
file,
|
||||
prefix,
|
||||
intro,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
@@ -75,6 +89,18 @@ impl ChrRaces {
|
||||
self.0.get(&race).map(|r| r.intro)
|
||||
}
|
||||
|
||||
/// The race row's client prefix (`+0x18`, `0x479a21`) that a helm model's name is built with;
|
||||
/// a race with no row has none, and `0x4799a0` attaches no helm for it (`0x4799cc`-`0x4799e9`).
|
||||
pub fn client_prefix(&self, race: u32) -> Option<&str> {
|
||||
self.0.get(&race).map(|r| r.prefix.as_str())
|
||||
}
|
||||
|
||||
/// A table for tests of what reads the client prefix: `race` holds `prefix`.
|
||||
pub fn with_client_prefix(mut self, race: u32, prefix: &str) -> Self {
|
||||
self.0.entry(race).or_default().prefix = prefix.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// A table for tests of what reads the intro column: `race` holds `sequence`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_intro_sequence(mut self, race: u32, sequence: u32) -> Self {
|
||||
@@ -90,7 +116,8 @@ impl ChrRaces {
|
||||
fn schema() -> Schema {
|
||||
let mut s = Schema::new("ChrRaces");
|
||||
for i in 0..CHR_RACES_FIELDS {
|
||||
let ty = if i == FILE_FIELD
|
||||
let ty = if i == PREFIX_FIELD
|
||||
|| i == FILE_FIELD
|
||||
|| (NAME_FIELD..NAME_FIELD + 8).contains(&i)
|
||||
|| (26..=28).contains(&i)
|
||||
{
|
||||
@@ -122,6 +149,7 @@ fn table_from_bytes(bytes: &[u8], locale: Locale) -> Result<ChrRaces> {
|
||||
Row {
|
||||
name: loc_str(&rs, r, NAME_FIELD, locale).unwrap_or_default(),
|
||||
file: str_at(&rs, r, FILE_FIELD).unwrap_or_default(),
|
||||
prefix: str_at(&rs, r, PREFIX_FIELD).unwrap_or_default(),
|
||||
intro: u32_at(r, INTRO_FIELD).unwrap_or(0),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -44,6 +44,12 @@ impl ShapeshiftForm {
|
||||
self.flags & 0x8 != 0
|
||||
}
|
||||
|
||||
/// Flag bit `0x8` of `flags1` also lets the form use any item: `CGItem::Use` refuses an item
|
||||
/// in a form without it, unless bit `0x40` and the item is equippable (`0x5d9136`-`0x5d914d`).
|
||||
pub fn allows_items(&self, equippable: bool) -> bool {
|
||||
self.flags & 0x8 != 0 || (self.flags & 0x40 != 0 && equippable)
|
||||
}
|
||||
|
||||
/// Clicking the active form's button cancels its aura (`CMSG_CANCEL_AURA`) unless bit `0x2`
|
||||
/// makes it a silent no-op (`CastShapeshiftForm` `0x4b4810`, guard at `0x4b4963`); the
|
||||
/// warrior stances (`0x7`) set it on 5875.
|
||||
|
||||
Reference in New Issue
Block a user