Table of contents
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
General notes
The sprite size formula:
# mul2 == mul3 == 160
# scale2 == 200
# var3 == ???
baseH <- ((sprite.H * obj.Scale)/256*scale2)/256
baseW <- (sprite.W * obj.Scale)/256
pX,pY <- project(obj.X-camera.X,obj.Y-camera.Y)
if pY<=20 then return
pX <- pX + var3
WW <- (baseW*mul2)/pY
HH <- (baseH*mul3)/pY
XX <- (pX*mul2)/pY + centerX - WW/2
YY <- (((sprite.Z+camera.Z)-baseH)*mul3)/pY + centerY
Monsters
The monster races are 1-based. The lists below are indexed by race - 1. There are 24 different monster types.
- Names:
szlist @3A8EE - Level: byte array
@42096(zero-based, as with the player) - Hit points: 48-entry
WORDarray@420DE(min, max format) - Base experience:
DWORDarray@4213E. - Experience multiplier: byte array
@4219E - Sound: byte array
@4201E. An index into theVOCszlist @437CD - Damage: 48-entry byte array
@421B6(min, max format) - Magical effects:
WORDarray@420AE(these go into the NPC'sActiveEffects) - Scale:
WORDarray@42066(in 1/256s, 0 = 100%) - Y offset: signed byte array
@42036 - Has no corpse: byte array
@4204E - Blood: byte array
@4762F(index into animation list@42EFC) - Disease chance: signed byte array
@42212. Negative values have special meaning.
Monster experience is calculated as baseExperience + maxHP * experienceMultiplier.
- Animation:
@4222B. Monsters have a special 3-frame death animation at the index 5 (...6).
00..05 walk
06..07 look around (a special sequence of 6, 0, 7, 0)
08..11 attack (damage is done in the frame 10)
AI
Every game tick the following occurs (incomplete).
void AI_Update(NPCSprite* npc) {
// Citizen
if(npc->flags & 4) {
if(npc->flat == (DAT_4b80_c7a8 + DAT_4b80_c200)) { // dying flat
npc->frame = -1;
}
else {
AI_Citizen_Move(npc);
}
return;
}
// Enemy
if(npc->flat == 197 || npc->flat == (DAT_4b80_c7a8 + DAT_4b80_c200)) { // dying flat
return;
}
NPCData* npcData = GetNPCData(npc->data);
if(npcData->statusFlags & 0x0040) { // paralyzed
npc->frame = 0; // standing
return;
}
// Calculate speed based on current Speed stat
npc->speed = ((npcData->currentSpeed * 20) >> 8) + 20;
// Do melee hit and early exit if monster and already attacking. Humanoid melee hits are handled in the animation function.
if(npcData->statusFlags & 0x1000 && npc->frame >= 8) {
if(npc->frame == 10) { // second-to-last frame of attack
Attacker = npcData;
Defender = PlayerData;
PerformMeleeAttack(); // calculate melee hit or miss, apply damage, etc.
}
return;
}
// Randomly turn a little, to be overridden if the player is not hidden
uint16 facingDir = (GetRandomNumber() >> 13) + npc->angle;
facingDir &= 0x1FF; // limit to 512 values
// Check for invisibility and no-target effects
bool playerHidden = IsPlayerInvisible() || (ActiveEffects & 0x0002);
if(!playerHidden) {
// Get direction to player
facingDir = ComputeDirection(npc->x, npc->z, PlayerX, PlayerZ);
}
else {
npc->frame = 0; // standing
}
// Set direction
npc->angle = facingDir;
if(npcData->statusFlags & 0x1000) { // monster
npc->flags &= ~0x0010; // clear melee flag
}
// Melee attack
if(!playerHidden) {
int dx = abs(npc->x - PlayerX);
int dz = abs(npc->z - PlayerZ);
if(dz <= dx) swap(dx, dz);
dx >>= 2;
dx += dz;
if(dx <= 170 && ((PlayerY >= 0) || (Unknown < 1)) { // in melee range and player Y is high enough, or unknown condition
if(actor->statusFlags & 0x1000) { // monster
npc->flags |= 0x0010; // set melee flag
npc->flags &= ~0x0001; // clear spellcasting flag
if(npc->frame > 5) { // if looking around finish that first?
return;
}
}
else { // humanoid
if(npc->flags & 0x0010) // already doing melee attack
return;
}
npc->frame = 0; // standing
if((GetRandomNumber() & 7) != 0) { // randomly delay
return;
}
if(!(npcData->statusFlags & 0x1000)) { // humanoid
npc->frame = 5; // start of humanoid melee attack
npc->flags |= 0x0010; // set melee flag
}
else {
npc->frame = 8; // start of monster melee attack
}
return;
}
}
// Move
if (!playerHidden)
{
if(npc->flags & 0x0001)) { // spellcasting
npc->frame = 0; // standing
else
AttemptMoveTowardsPlayer(npc);
}
// Spellcasting
npc->flags &= ~0x0001; // clear spellcasting flag
if(!IsPlayerInvisible()) // check for invisibility
{
if(!(npcData->activeEffects & 0x0200)) { // not silenced
if(!(npcData->statusFlags & 0x1000)) { // humanoid
if((npcData->class & 0x20) && !IsLOSBlocked(npc)) { // spellcaster and LOS with the player
uint8_t spell_id;
Spell* spell = SelectSpell(npcData, &spell_id); // randomly select a castable spell
if(!spell) return; // abort if there were no castable spells
npc->flags |= 0x0001; // set spellcasting flag
if(GetRandomNumber() % 0x16 == 0) {
npcData->spellPoints -= spell->spellCost;
AI_PerformSpellCast(); // TODO
}
}
}
else { // monster
// spellcaster, no obstructions to the player and is either not a medusa or the player is not paralyzed
if(npcData->knownSpellCount > 0 && !IsLOSBlocked(npc) && (npcData->monsterID != 0x15 || !(PlayerStatusFlags & 0x40))) {
uint8 spellID = npcData->knownSpellIDs[GetRandomNumberFromRange(0, npcData->knownSpellCount)]; // select random spell
if(GetRandomNumber() % 0x16 == 0) {
PerformSpellCast(); // TODO
}
}
}
}
}
return;
}
bool IsPlayerInvisible(NPCSprite* npc)
{
if(((ActivePlayerEffects & 1) != 0) && (InvisibilityOffTimer == 0)) // if player has the "invisibility" effect and is not temporarily visible
{
NPCData* npcData = GetNPCData(npc->data);
if(((npcData->statusFlags & 0x1000) == 0) || // not a monster
((((npcData->monsterID != 20 && (npcData->monsterID != 12)) && (npcData->monsterID != 15)) &&
(((npcData->monsterID != 23 && (npcData->monsterID != 22)) && (npcData->monsterID != 42)))))) // not a fire daemon, ghost, wraith, lich, vampire, or last boss
{
return false;
}
}
return true;
}
int ComputeDirection(int fromX, int fromZ, int toX, int toZ) {
uint16 bpFlags;
int dir = ComputeDelta(fromX, fromZ, toX, toZ, &bpFlags);
if(bpFlags & 0x2) { // flip vertically
dir = -dir + 0xFF;
}
if(bpFlags & 0x1) { // flip horizontally
dir = -dir + 0x1FF;
}
return dir;
}
// Returns scaled slope between two points (0x40 = 45 degrees).
// Sets BP bit 0 = X sign flag, bit 1 = Z sign flag.
int ComputeDelta(int fromX, int fromZ, int toX, int toZ, uint16* bpFlags) {
*bpFlags = 0;
int dx = toX - fromX;
if(dx < 0) {
*bpFlags |= 0x1;
dx = -dx;
}
int dz = toZ - fromZ;
if(dz < 0) {
*bpFlags |= 0x2;
dz = -dz;
}
if(dx <= dz) {
// slope = dx / dz * 0x40
int a = dx, b = dz;
if(b == 0) b = 1;
return (a * 0x40) / b;
}
else {
// slope = dz / dx * 0x40, negated and offset
int a = dz, b = dx;
if(b == 0) b = 1;
return ((-(a * 0x40) / b) + 0x7F);
}
}
// Returns a random spell for a humanoid if conditions are met
Spell* SelectSpell(NPCData* npcData, uint8_t* out_id) {
if(!(npcData->class & 0x20)) // not a spellcaster
return NULL;
// Required spell point threshold based on level
int requiredSpellPoints = 200 / (npcData->level + 1);
if(npcData->spellPoints < requiredSpellPoints)
return NULL;
while(1) {
int idx = GetRandomNumberInRange(0, 16);
uint8 spellID = HumanoidEnemySpellList[idx]; // @418B9
struct Spell* spell = GetSpellRecord(spellID);
int costThreshold = spell->spellCost / (npcData->level + 1);
if(npcData->spellPoints >= costThreshold) {
*out_id = idx;
return spell; // found a castable spell
}
}
}
void AttemptMoveTowardsPlayer(NPCSprite* npc)
{
int facing = npc->angle;
// Try moving in current facing direction. IsStepBlocked will zero the X or Z step if it will avoid collision.
if(IsStepBlocked(npc, facing))
return;
if(IsStepTooShort(npc, step.x, step.z)) {
// Set default coordinates
tmpPos1.x = tmpPos2.x = tmpPos3.x = tmpPos4.x = 0x6000;
// Calculate right turn (+0x64)
if(!IsStepBlocked(npc, facing + 0x64))
{
// Set position 1 with the new X and Z calculated within IsStepBlocked
tmpPos1.x = newX; tmpPos1.z = newZ;
}
// Calculate left turn (−0x64)
if(!IsStepBlocked(npc, facing - 0x64))
{
// Set position 2 with the new X and Z calculated within IsStepBlocked
tmpPos2.x = newX; tmpPos2.z = newZ;
}
// Pick position closer to player
int distRight = Distance_Manhattanish(tmpPos1.x, tmpPos1.z, PlayerX, PlayerZ);
int distLeft = Distance_Manhattanish(tmpPos2.x, tmpPos2.z, PlayerX, PlayerZ);
if(distRight < distLeft)
{
newX = tmpPos1.x; newZ = tmpPos1.z;
}
else
{
newX = tmpPos2.x; newZ = tmpPos2.z;
}
}
npc->x = newX;
npc->z = newZ;
}
// Returns true if blocked. WIP
bool IsStepBlocked(NPCSprite* npc, int angle) {
angle &= 0x1FF;
int speed = npc->speed * 2;
// Look up cosine/sine from precomputed tables, multiply by speed and add to current position
int newX = npc->x + ((speed * CosineTable[angle]) >> 16); // @466A6
int newZ = npc->z + ((speed * SineTable[angle]) >> 16); // @467A6
// Check for collision (and handle opening doors?).
// If the newX and new Z are OK, keep them as is.
// If not, try newX with currentZ.
// If collided, try currentX with newZ.
// If collided, movement is blocked.
// Check if the new coordinates (one or both may have been set to current X or Z) are already occupied
if(!IsTileOccupied(newX, newZ))
return false; // OK to move
return true; // Way is blocked
}
// Checks if calculated step is not long enough.
// This can happen when the step in either the X or Z axis was canceled to avoid collision.
bool IsStepTooShort(NPCSprite* npc, int xStep, int zStep) {
int dx = abs(xStep - npc->x);
int dz = abs(zStep - npc->z);
// Make sure dx <= dz
if(dx > dz) swap(dx, dz);
dx >>= 2;
dx += dz;
int threshold = npc->speed >> 2;
return (dx < threshold);
}
// Distance function: quarter of larger delta plus smaller delta
int Distance_Manhattanish(int x1, int z1, int x2, int z2) {
int dx = abs(x1 - x2);
int dz = abs(z1 - z2);
if(dx < dz) swap(dx, dz);
dx >>= 2;
return dx + dz;
}
bool IsLOSBlocked(NPCSprite* npc)
{
npc->flags &= 0xf7ff; // clear LOS flag
uint16 startX = npc->x >> 7;
uint16 startZ = npc->z >> 7;
uint16 endX = PlayerX >> 7;
uint16 endZ = PlayerZ >> 7;
bool LOSBlocked = IsLOSBlocked_Core(startX, startZ, endX, endZ); // TODO
if(!LOSBlocked) {
npc->flags |= 0x800; // set LOS flag
}
return LOSBlocked;
}
Sound
Every game tick, for nearby enemies:
if(MonsterSoundIndex > -1) {
if(GetRandomNumber() <= 2000) {
// Play monster sound
}
}
Animation
Every game tick:
if(npcData->statusFlags & 0x1000) { // is a monster
if(npc->flat == 0xC5) { // is in dying animation
if(npcData->CurrentHP < 1) {
npc->frame++;
if(npc->frame < 3) // haven't reached end of animation yet
return;
if(MonsterHasNoCorpse) {
npc->flags |= 0x4000; // hide sprite?
npc->frame = 0;
return;
}
else {
npc->frame = 2; // reset to final dying frame
if(npc->playedBodyFall == 0) {
npc->playedBodyFall++;
// Play "BODYFALL.VOC" at the NPC's coordinates
}
}
}
else { // resurrect
npc->frame--;
if(npc->frame < 0) { // animation finished reversing
npc->flat = 0xC0; // facing camera
npc->flags &= ~0x0100; // clear dead flag
npc->frame = 0; // standing
return;
}
}
}
if(npc->flags & 0x0001) { // spellcasting
return;
}
else {
if(npc->frame < 6) { // walking
if((GetRandomNumber() & 0x1f) == 0) {
if(npcData->monsterID != 0x10) { // not a homonculus
npc->flags |= 0x0040; // set looking flag
}
}
if((UpdateCount & 0x1) != 0 && !(npc->flags & 0x0010)) { // not in melee
if(AI_IsPlayerInvisible()) {
npc->frame = 0;
}
else {
npc->frame++;
if(npc->frame >= 6) {
npc->frame = 0;
}
}
}
else if(npc->frame < 8) { // unreachable since nothing sets animation frame to 6 or 7
if(npc->flags & 0xC0) { // looking left and right
int flagPart = npc->flags & 0xC0;
int newFrame;
if(flagPart == 0x40) newFrame = 6; // first looking frame
else if(flagPart == 0x80) newFrame = 0; // looking straight
else newFrame = 7; // both flags set. Second looking frame.
npc->frame = newFrame;
npc->flags += 0x40; // advance flags to next part of sequence
return;
}
}
}
}
}
else { // humanoid
if(npcDataFlags & 0x0100) { // dead flag set
npc->frame = -1;
npc->flat = (DAT_4b80_c7a8 + DAT_4b80_c200);
npc->flags &= ~0x0010; // clear melee flag
return;
}
if(!(npc->flags & 0x0010)) { // not in melee state
if(npc->frame == 8) { // at last melee frame
return;
}
if(npc->flags & 0x0001 || AI_IsPlayerInvisible()) { // spellcasting flag is set or player is invisible
npc->frame = 0; // standing
return;
}
else {
// animate walking frames
npc->frame++;
if(npc->frame >= 6) {
npc->frame = 0;
}
return;
}
}
else { // in melee state
npc->frame++;
if(npc->frame < 9) { // still advancing through melee frames (6 through 8)
return;
}
// Reached the end of the attack animation
AttackingActor = npcData;
DefendingActor = PlayerData;
AI_PerformMeleeAttack(); // calculate melee hit or miss
npc->frame = 8; // reset to last attack frame
npc->flags &= ~0x0020; // clear unknown flag
}
}
Humanoids
Humanoids have an animation combined of walk and attack part. The attack part is also overlaid with a weapon animation, when applicable.
- If an NPC has a cuirass, or at least 3 other items, of a certain armor type, that type is used for the sprite (indices 0..2)
- Otherwise, it is 3
- For spellcasters, it is 4, for Monks 5, and for Barbarians 6.
The sprite type array is located @45D20.
- The weapon index is 0 to 2 for swords, axes, and maces correspondingly.
- If the sprite index was 0, weapon index increases by 3, if 6 by 6.
A female variant is not selected for the plate armor sprite (index 0).
The weapon sprite array is located @45D97
- For monks and mages, the special attack animations are used,
MNKKIC,MAGSWD, andMAGSTF.
Humanoids do not have the death animation, they are replaced with a 'corpse' sprite (ITEM 2).
Max Health
Humanoids roll for max health in the same manner as the player, using the same per-class health dice. However, instead of having 25 added to the total as the player does in character generation, they roll their level + 1 times. (TODO: Needs more research)
Experience
Humanoid experience values are calculated as follows. Note that this function appears to not work as intended because the class value does not have ID_MASK applied to it, resulting in every class getting the "warrior type" multiplier.:
void SetHumanoidEXP(NPCSTATS* npcStats)
{
unsigned char class = npcStats->Class;
int index = 0;
if (class > 5) // Not a mage-type class
{
index = 1;
if (class > 12) // Not a thief-type class
{
index = 2;
}
}
unsigned char level = npcStats->Level;
npcStats->Experience = (level * level * HumanoidExpModifiers[index]);
return;
}
HumanoidExpModifiers `@43591`
0Fh // Mage type
14h // Thief type
19h // Warrior type
Townsfolk
Townsfolk do not have NPC properties. They are dying with a single hit, yielding 1..4 gold and corresponding message.
Male sprites (@4559A) are chosen by the tileset. Special female sprites are chosen for the desert tileset (FMGEND) and snow/snow overcast weather (FMGENW).
Color transformation
For clothing transformation, the random value Data & 0x7fff is used. colorBase is a byte array @47096.
val <- seed
for i <- 0, 16
flag <- val & 0x8000
val <- rol16(val,1)
if flag then
block <- val & 0xF
dest <- colorBase[i]
if dest == 128 and block == 11 then continue # no green hair
src <- colorBase[block]
for j <- 0, 10
new[src+j] <- old[dest+j]
For skin transformation, the following values are used:
- Bretons, Nords, Wood Elves, Khajiits - no transformation
- Dark Elves -
52 - High Elves -
192 - Argonians -
116 - Everyone else -
148
skinColor is a byte array @470A6.
for i <- 0, 10
new[skinColor[i]] <- old[VAL+i]
Movement logic (whether to stop and idle near the player or wander around the area) is as follows:
int xDiff = PlayerX - CitizenX;
if (xDiff < 0) {
xDiff = -xDiff;
}
int zDiff = PlayerZ - CitizenZ;
if (zDiff < 0) {
zDiff = -zDiff;
}
int shorterDist = xDiff;
int longerDist = zDiff;
if (zDiff <= xDiff) {
shorterDist = zDiff;
longerDist = xDiff;
}
int calculatedDistance = (shorterDist >> 2) + longerDist;
if ((calculatedDistance < 200) &&
((MouseCursorIsXIcon || PlayerTargetMoveSpeed == 0) || !LeftMouseButtonPressed) &&
(!PlayerWeaponDrawn && !PlayerInvisible)) {
npcIdlingFlag = true;
if (npcSprite->Frame < 6) {
npcSprite->Frame = 6;
return;
}
}
else {
npcIdlingFlag = false;
int coordinateOfMovement;
if ((npcSprite->Angle & 0x80) == 0) { // Moving along the Z-axis, so center on the X-axis of the voxel
npcSprite->X = npcSprite->X & 0xff80;
npcSprite->X += 0x40;
coordinateOfMovement = npcSprite->Z;
}
else { // Moving along the X-axis, so center on the Z-axis of the voxel
npcSprite->Z = npcSprite->Z & 0xff80;
npcSprite->Z += 0x40;
coordinateOfMovement = npcSprite)->X;
}
if ((49 < (coordinateOfMovement & 0x7f)) && ((coordinateOfMovement & 0x7f) < 79)) { // Collision check every once in a while
bool collision = TownspersonCollisionCheck();
if (collision) {
int angleChange = 128;
ushort rand = GetRandomNumber();
if (rand + rand > 65535) { // If adding the 16-bit number from GetRandomNumber() to itself overflows, in other words a 1 out of 2 random chance
angleChange = -angleChange;
}
npcSprite->Angle += angleChange; // Ex. Change from angle 0 to angle -128.
npcSprite->Angle = npcSprite->Angle & 0x1ff; // Limit to 511 or lower
return;
}
}
int index = (npcSprite->Angle >> 7) * 4; // Angle >> 7 will be a value from 0 to 3
int xMovement = CitizenMovementsX[index]; // 0, 16 or -16
int zMovement = CitizenMovementsZ[index]; // 0, 16 or -16
npcSprite->X += xMovement;
npcSprite->Z += zMovement;
}
return;
}
// CitizenMovementsX are at offset 0x45586, and CitizenMovementsZ are at 0x45588, in the unpacked 1.06 executable.
// You could also treat them like a 4-element array of XZ pairs starting from 0x45586.
00 00 // X0 0x45586
10 00 // Z0 0x45588
10 00 // X1
00 00 // Z1
00 00 // X2
F0 FF // Z2
F0 FF // X3
00 00 // Z3
Citizen animation is as follows:
// If idle flag set -> idle handling:
if(npc->flags & 0x0001) {
// If frame > 6, we are in the idling frames
if(npc->frame > 6) {
// RNG gating: call RNG, accept 1-in-8 chance to advance frame
if((GetRandomNumber() & 0x7) != 0)
return;
npc->frame++;
return;
}
// else test UpdateCount low bits: only advance on one in eight ticks
if((UpdateCount & 0x7) != 0)
return;
npc->frame++;
if(npc->frame > 8)
npc->frame = 6; // clamp back into idle range
return;
}
// walking
npc->frame++;
if(npc->frame >= 6)
npc->frame = 0;
return;