SCRMaps
/
Sign in

Knowing that a unit just attacked

Lifesteal, crits, on-hit procs. There is no attack event, but the cooldown gives one away.

11 min read

Lifesteal, a crit, anything that fires “every time it hits” needs to know that a unit attacked. StarCraft has no such event. What it has is the weapon cooldown: it jumps to its maximum the instant the unit fires and only ever counts down afterwards. There are two ways to use that, and they are different in character.

One fact first: StarCraft randomises every attack's cooldown by plus or minus 2. A Marine's 15 becomes 17, 16, 15, 14 or 13 - which is why a line of Marines goes brrrrt rather than firing in unison. So watching for “cooldown == 15” misses four shots in five.

// 마린은 쿨다운 15 → 12(0xC)를 봅니다 / Marine: cooldown 15, so watch 12
if (MemoryXEPD(unitEPD + 0x54/4, Exactly, 0xC00, 0xFF00)) {
    // 이번 프레임에 쾏습니다 / it fired this frame
}
Max cooldown minus 3. All five variants count down through that value, so none is missed. 0x54 masked 0xFF00 is the byte at 0x55, the ground weapon cooldown.

One condition and no state to keep - far cheaper than watching hit points or scanning for projectiles. It has exactly one limit: a weapon with a cooldown of 3 or less has no such window.

The second way keeps the previous frame's cooldown per unit and fires on any increase. A cooldown only ever counts down on its own, so ANY rise is a shot - whatever the weapon, whatever the randomisation, however short the cooldown.

const prevCD = dwread_epd(extra + OFFSET_GCOOLDOWN);
if (unit.gCooldown > prevCD) { /* 쌀다 / it fired */ }
SetMemoryEPD(extra + OFFSET_GCOOLDOWN, SetTo, unit.gCooldown);

Choosing between them is clear: the window when you have one weapon and care about cost, the rise when the map arms many different units or when a weapon is too fast for a window. The rise costs one stored value per unit.

Where to put that one value per unit is the next problem, and CUnit's genuinely free bytes run out fast. ex_cunit allocates a parallel block of extra fields, one per unit slot.

import ex_cunit;
const MyCUnit = ex_cunit.ExtraCUnit();
const OFFSET_GCOOLDOWN = 0;
const OFFSET_MANA      = 1;

const extra = MyCUnit.jump_epd(unitEPD);   // 이 유닛의 추가 블록
dwread_epd(extra + OFFSET_GCOOLDOWN);
Install ex_cunit.py into BuildData/eudplibData/TriggerEditor.
What ex_cunit actually is
Db(1700 * 336)A parallel block exactly the size of the real CUnit table. That is the point: the distance from a unit's CUnit to its extra data is a constant, so the jump is one action.
84 fields336 bytes is 84 four-byte fields, per unit.
You must initialise it yourselfStarCraft zeroes a CUnit when it hands out the slot; it has never heard of your parallel block. Clear it yourself in EUDLoopNewUnit() - otherwise the previous occupant's values are still sitting there.

Once you know it fired, what it hit comes from two fields: 0x5C is the order's target UNIT and 0x58 is the order's target POSITION - one for “what did I hit” and one for “where did I click”.

const tPtr, tEpd = cunitepdread_epd(unitEPD + 0x5C/4);   // 대상 유닛
const x, y   = posread_epd(unitEPD + 0x28/4);
const tx, ty = posread_epd(tEpd + 0x28/4);
setloc("fx", x, y);
for (var i = 0; i < 12; i++) {
    var dx, dy = lengthdir(64, atan2(ty - y, tx - x));
    addloc("fx", dx, dy);                                 // 대상 쪽으로 걸어감
    /* 그리거나 피해를 줍니다 / draw or damage */
}

Two things worth knowing from the same place. A unit that is out of ammunition has its groundWeaponCooldown pinned at 2 - check that first if a cooldown never counts down. And to freeze a value the engine increments every frame, do not overwrite it with SetTo: compare against what you want and subtract the difference. Overwriting races the engine's increment and jitters.

Damage and projectiles of your ownThree ways to read the struct wrong
Sign in to track your progress