Created
September 22, 2022 08:33
-
-
Save ItsCuzzo/4d671e307339a617a9e4001aff58fff0 to your computer and use it in GitHub Desktop.
The core functionality from my "Optimising Gamified 2D Grid Movement" series on Medium.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// SPDX-License-Identifier: UNLICENSED | |
pragma solidity 0.8.17; | |
import { ICity } from "./interfaces/ICity.sol"; | |
/// @title Inspired by the Hobo Wars daily event of city exploration. | |
/// @author ItsCuzzo | |
/// @dev Source: https://wiki.hobowars.com/index.php?title=Exploring | |
/// Memory layout per invoke, assumes the transaction succeeds. Numbers provided | |
/// here are indicative of the 'happy' path. | |
/// | |
/// ----------------------------------------------------------------------------- | |
/// | Offset | Writes | Description | | |
/// |-----------------------------------------------------------------------------| | |
/// | 0x00 | 3 | Scratch space for hashing methods. | | |
/// | 0x20 | 3 | Scratch space for hashing methods. | | |
/// | 0x40 | 0 | Free memory pointer. | | |
/// | 0x60 | 0 | Zero slot. | | |
/// | 0x80 | 1 | Stores `block.timestamp` value. | | |
/// | 0xA0 | 100 | Stores `position` value. | | |
/// | 0xC0 | 25 | Stores `i` value. | | |
/// | 0xE0 | 100 | Stores magic value as defined in Solidity implementation. | | |
/// ----------------------------------------------------------------------------- | |
contract CityAsm is ICity { | |
/// @dev The state variables defined in the Solidity implementation have been | |
/// packed into a single storage slot. This optimisation drastically reduces | |
/// the number of SLOADs that are required within our `explore` logic. | |
/// | |
/// `0x73209C3093A80` is a hex value with the variables below packed as follows. | |
/// | |
/// ------------------------------------------------------------------------------------------------- | |
/// | Bit Position | Type & Value | Description | | |
/// |-------------------------------------------------------------------------------------------------| | |
/// | 0...23 | uint24(7 days) | cooldown: Duration a player must wait between explorations. | | |
/// | 24..39 | uint16(2499) | mapSize: Number of tiles within the grid, inclusive of 0. | | |
/// | 40..47 | uint8(50) | lootChance: Chance a player has to find loot at a given tile. | | |
/// | 48..55 | uint8(7) | maxCans: Maximum number of cans that be found at a tile. | | |
/// ------------------------------------------------------------------------------------------------- | |
/// | |
uint256 public config = 0x73209C3093A80; | |
/// @dev Maps an `address` to a packed `uint256`. The `uint256` value is packed as follows: | |
/// | |
/// --------------------------------------------------------------------------------------- | |
/// | Bit Position | Type | Description | | |
/// |---------------------------------------------------------------------------------------| | |
/// | 0....127 | uint128 | lastExplore: Timestamp of the last invoke of `explore`. | | |
/// | 128..191 | uint64 | cansFound: Total number of cans found by the player. | | |
/// | 192..255 | uint64 | nonce: Used to roll starting position of the player. | | |
/// --------------------------------------------------------------------------------------- | |
/// | |
mapping(address => uint256) private _players; | |
/// @notice Function used to explore the city. | |
/// @param moves Hex representation of the desired move set. | |
function explore(bytes32 moves) external { | |
assembly { | |
/// Assign `c` the `config` value from storage. | |
let c := sload(config.slot) | |
/// Parse out the value of `mapSize` from `c`. We do this by shifting the | |
/// value of `c` by 24 bits to the right and then masking out the 16 least | |
/// significant bits using `0xFFFF`. | |
let size := and(shr(24, c), 0xFFFF) | |
/// Parse out the value of `lootChance` from `c`. We do this by shifting the | |
/// value of `c` by 40 bits to the right and then masking out the 8 least | |
/// significant bits using `0xFF`. | |
let chance := and(shr(40, c), 0xFF) | |
/// Parse out the value of `maxCans` from `c`. We do this by shifting the | |
/// value of `c` by 48 bits to the right and then masking out the 8 least | |
/// significant bits using `0xFF`. | |
let max := and(shr(48, c), 0xFF) | |
/// Calculate the storage slot of `_players[msg.sender]`. | |
/// Source: https://docs.soliditylang.org/en/v0.8.17/internals/layout_in_storage.html | |
mstore(0x00, caller()) | |
mstore(0x20, _players.slot) | |
/// Cache the storage slot of `players[msg.sender]` as we will need to reuse it | |
/// later. Caching this value will allow us to easily write to this storage slot | |
/// at a later point in time as we don't need to recalculate the value. | |
let p := keccak256(0x00, 64) | |
/// Cache the value of storage slot `players[msg.sender]` as it will be | |
/// used twice. Doing this avoids the need for an additional SLOAD. | |
let v := sload(p) | |
/// Check if `msg.sender` is on cooldown. To do so, we mask out both the `lastExplore` | |
/// value from `v` and the `cooldown` value from `c`. If they are, store the error | |
/// selector of `Exhausted()` in memory and revert. | |
if iszero(gt(sub(timestamp(), and(v, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)), and(c, 0xFFFFFF))) { | |
mstore(0x00, 0x6f3d6c4f) | |
revert(0x1c, 0x04) | |
} | |
/// Assign `cans` the value of `cansFound` from `_players[msg.sender]`. | |
let cans := and(shr(128, v), 0xFFFFFFFFFFFFFFFF) | |
/// Parse out the `nonce` value from `players[msg.sender]`. We do this by | |
/// shifting the value of `v` 192 bits to the right and then masking | |
/// out the 64 least significant bits using `0xFFFFFFFFFFFFFFFF`. | |
let nonce := and(shr(192, v), 0xFFFFFFFFFFFFFFFF) | |
/// Store both `caller` and `nonce` in scratch space. | |
mstore(0x00, caller()) | |
mstore(0x20, nonce) | |
/// Calculate the starting position of the player. We add 1 to | |
/// ensure that `pos` can fall within the limits of 0 to `mapSize`. | |
let pos := add(mod(keccak256(0x00, 64), size), 1) | |
/// Define a variable `i` that will be used to track each iteration | |
/// of our for loop. | |
let i | |
/// Define a variable `set` that will be assigned a series of 4 | |
/// moves (1 byte) from `moves`. | |
let set | |
/// Define a variable `change` that will be used to track the | |
/// value that `pos` will change by. | |
let change | |
/// Define a variable `move` that will be assigned a singular | |
/// move value in the limits of 0 to 3. | |
let move | |
/// Define a variable `eventId` that will be assigned a value | |
/// to be used in loot rolling. | |
let eventId | |
/// Since `block.timestamp` is used multiple times throughout our | |
/// loot rolling process, we can store it once in memory and reuse | |
/// it. This works because `block.timestamp` is guaranteed to remain | |
/// constant throughout execution and will not change. | |
mstore(0x80, timestamp()) | |
/// Instead of using a 'traditional' assembly for loop, we opt to use | |
/// a more optimised implementation as showcased by @optimizoor. | |
/// | |
/// Source: https://www.twitter.com/SolidityFridays/status/1568168481324933121 | |
/// | |
for {} 1 {} { | |
/// Assign `set` the value of `moves[i]`. `set` now holds a uint8 value | |
/// that represents the 4 moves associated with byte `i`. | |
set := byte(i, moves) | |
/// You might notice that we no longer have an inner loop. One gas optimisation | |
/// trick that was utilised here is loop unrolling. It's relatively niche to | |
/// encounter situations where this can be used and one of the unfortunate side | |
/// effects is that it comes with repetitiveness and hindered readability. | |
/// Parse Move 1. | |
/// Since moves are read from left-to-right, we shift `set` by 6 bits. Since | |
/// we are only working with a single byte value at a time, we can safely assume | |
/// that the upper bits of `set` are clean. | |
/// | |
/// E.g. 11 00 01 00 >> 6 = 00 00 00 11 | |
/// | |
move := shr(6, set) | |
/// Another nifty gas optimisation that saves around 350 gas. This is a branchless | |
/// implementation of an if/else if/else statement inspired from @fiveoutofnine. | |
/// | |
/// Source: https://www.twitter.com/fiveoutofnine/status/1491894008049811458 | |
/// | |
change := and(shr(shl(3, move), 0x32010132), 0xFF) | |
/// This switch statement is a beautiful example of when things just work. After | |
/// implementing the above logic, I needed a way to determine if the value of `pos` was | |
/// getting incremented or decremented. | |
/// | |
/// If you recall the way movement within our system works, movements in either the | |
/// left (2) or up (3) directions result in a tile decrease and movements | |
/// in either the right (1) or down (0) directions result in a tile increase. | |
/// | |
/// Since values of 1 and 0 result in a tile increase, we can simply shift the value | |
/// of `move` to the right by 1 and then check the value. If the value is 0, that means | |
/// we need to increment `pos`. If it's not, we decrement it. | |
/// | |
switch shr(1, move) | |
case 0 { pos := add(pos, change) } | |
default { pos := sub(pos, change) } | |
/// Check if the player has moved out of bounds. If they have, we store the error | |
/// selector for `OutOfBounds()` in scratch space and revert. | |
if gt(pos, size) { | |
mstore(0x00, 0xb4120f14) | |
revert(0x1c, 0x04) | |
} | |
/// The next portion of code is responsible for rolling to determine if a player has | |
/// found any cans at their current position. The Solidity implementation equivalent is: | |
/// | |
/// keccak256(abi.encode(block.timestamp, position, i, j)); | |
/// | |
/// Since we aren't using an inner loop, we have to provide a magic value to match the | |
/// conditions of the Solidity implementation. | |
/// | |
mstore(0xA0, pos) | |
mstore(0xC0, i) | |
mstore(0xE0, 0) | |
/// Calculate the `eventId` seed. | |
eventId := keccak256(0x80, 128) | |
/// Calculate if the player has found any cans, if so, how many cans did they find? | |
if lt(mod(eventId, 100), chance) { | |
cans := add(cans, add(mod(eventId, max), 1)) | |
} | |
/// Parse Move 2. | |
move := and(shr(4, set), 0x3) | |
change := and(shr(shl(3, move), 0x32010132), 0xFF) | |
switch shr(1, move) | |
case 0 { pos := add(pos, change) } | |
default { pos := sub(pos, change) } | |
if gt(pos, size) { | |
mstore(0x00, 0xb4120f14) | |
revert(0x1c, 0x04) | |
} | |
mstore(0xA0, pos) | |
mstore(0xE0, 2) | |
eventId := keccak256(0x80, 128) | |
if lt(mod(eventId, 100), chance) { | |
cans := add(cans, add(mod(eventId, max), 1)) | |
} | |
/// Parse Move 3. | |
move := and(shr(2, set), 0x3) | |
change := and(shr(shl(3, move), 0x32010132), 0xFF) | |
switch shr(1, move) | |
case 0 { pos := add(pos, change) } | |
default { pos := sub(pos, change) } | |
if gt(pos, size) { | |
mstore(0x00, 0xb4120f14) | |
revert(0x1c, 0x04) | |
} | |
mstore(0xA0, pos) | |
mstore(0xE0, 4) | |
eventId := keccak256(0x80, 128) | |
if lt(mod(eventId, 100), chance) { | |
cans := add(cans, add(mod(eventId, max), 1)) | |
} | |
/// Parse Move 4. | |
move := and(set, 0x3) | |
change := and(shr(shl(3, move), 0x32010132), 0xFF) | |
switch shr(1, move) | |
case 0 { pos := add(pos, change) } | |
default { pos := sub(pos, change) } | |
if gt(pos, size) { | |
mstore(0x00, 0xb4120f14) | |
revert(0x1c, 0x04) | |
} | |
mstore(0xA0, pos) | |
mstore(0xE0, 6) | |
eventId := keccak256(0x80, 128) | |
if lt(mod(eventId, 100), chance) { | |
cans := add(cans, add(mod(eventId, max), 1)) | |
} | |
/// Once code within the for loop has finished executing, we update our | |
/// iterator value, check to see if the stated condition has been met, and | |
/// continue onwards. | |
i := add(i, 1) | |
if iszero(lt(i, 25)) { break } | |
} | |
/// Update the `_players` mapping with the respective values packed | |
/// into a single word. | |
sstore( | |
p, | |
or(or(timestamp(), shl(128, cans)), shl(192, add(nonce, 1))) | |
) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment