72 NPCs
Allofich edited this page 2026-06-28 01:04:36 +09:00
This file contains ambiguous Unicode characters

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 WORD array @420DE (min, max format)
  • Base experience: DWORD array @4213E.
  • Experience multiplier: byte array @4219E
  • Sound: byte array @4201E. An index into the VOC szlist @437CD
  • Damage: 48-entry byte array @421B6 (min, max format)
  • Magical effects: WORD array @420AE (these go into the NPC's ActiveEffects)
  • Scale: WORD array @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, and MAGSTF.

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;