Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Point this script at a subfolder in your PUBG demos directory. Doesn't get all events (there are more in the UE4 checkpoint files), but will print some interesting stats about your match.
# ruby pubg-summarize.rb /mnt/c/Users/numinit/AppData/Local/TslGame/Saved/Demos/match.bro.official.2017-pre6.na.squad-fpp.2017.12.16.8585b819-02de-428e-bf07-19d9e721b782__USER__76561198040786185
Playing squad-fpp on Desert_Main - took 29.96 minutes
0.00: Weather: Clear, level Weather_Desert_Clear, weight 3
0.00: Map Desert_Main, weather Weather_Desert_Clear, region na, recorded by Hobocop, 0 players, 0 teams
=> Player senord, team 26 (ranked 0), 0 headshots, 0 kills, 0.00 damage, longest kill 0.00m, 0.00km covered
=> Player groxers, team 26 (ranked 0), 0 headshots, 0 kills, 0.00 damage, longest kill 0.00m, 0.00km covered
=> Player Hobocop, team 26 (ranked 0), 0 headshots, 0 kills, 0.00 damage, longest kill 0.00m, 0.15km covered
101.89: Berry-o knocked out UnnamedPone
105.52: Datidol knocked out T3lamon
107.32: Datidol killed T3lamon
111.07: Brjudge knocked out Datidol
111.34: sadHank knocked out Majoros
112.81: Brjudge killed Datidol
114.05: sadHank killed Majoros
117.61: Ozmucci knocked out AddUlyssesEtoFB
117.98: Brjudge knocked out rockfist93
119.00: Ozmucci killed AddUlyssesEtoFB
119.13: Guarnere87 knocked out Ozmucci
120.16: Brjudge killed rockfist93
122.55: SenpaiSaysNo knocked out Guarnere87
127.49: Berry-o killed SenpaiSaysNo
127.49: Berry-o killed UnnamedPone
131.23: Brjudge killed Berry-o
131.23: SenpaiSaysNo killed Guarnere87
133.03: Hobocop knocked out Alarassa
139.15: groxers killed Alarassa
163.57: Heifer911 knocked out Brjudge
166.61: cocainehussein knocked out sadHank
168.45: Heifer911 knocked out Ozmucci
169.55: akaRydog knocked out aredhairedfuck
173.47: Heifer911 killed Ozmucci
174.59: akaRydog killed aredhairedfuck
174.77: cocainehussein killed sadHank
175.52: Heifer911 killed Brjudge
184.43: Heifer911 killed cocainehussein
199.62: senord knocked out dakilla1779
207.04: Zackstacked knocked out PewPewMagoo
210.48: senord killed Zackstacked
210.48: senord killed dakilla1779
210.48: Zackstacked killed PewPewMagoo
306.64: senord knocked out KonnovarEOD
310.62: senord killed KonnovarEOD
321.04: Nebet knocked out groxers
322.86: senord killed Nebet
406.23: sKyfe killed BubbaSmith
1396.20: bbyy knocked out WaseyCakefield
1445.05: Heifer911 knocked out watwattest
1459.24: Heifer911 knocked out Scrubmarine
1465.56: IronDads knocked out bbyy
1470.74: TrashSloth knocked out Danz089
1473.94: Heifer911 killed watwattest
1473.98: Heifer911 killed Scrubmarine
1474.00: Heifer911 killed Nubhy
1480.58: NicTh1221 killed Danz089
1481.79: <nobody> knocked out Heifer911
1496.23: TrashSloth killed bbyy
1506.82: <nobody> killed Heifer911
1521.99: BingBangBoom killed Zakes_Dream
1531.44: JesusWalks knocked out NicTh1221
1553.91: JesusWalks killed NicTh1221
1611.94: a_aDTR knocked out DietarySupplemnt
1635.08: a_aDTR knocked out WaseyCakefield
1638.32: BingBangBoom knocked out Stoshakiss
1641.62: Bompy knocked out senord
1643.23: groxers killed Bompy
1643.23: BingBangBoom killed Stoshakiss
1648.52: BingBangBoom knocked out groxers
1650.17: Hobocop knocked out BingBangBoom
1651.78: a_aDTR killed DietarySupplemnt
1660.11: a_aDTR killed WaseyCakefield
1663.22: a_aDTR knocked out TrashSloth
1673.56: BingBangBoom killed groxers
1691.95: Hobocop killed BingBangBoom
1696.90: a_aDTR killed TrashSloth
1698.42: a_aDTR killed IronDads
1750.81: <nobody> knocked out Hobocop
1765.82: <nobody> killed Hobocop
1766.91: senord killed JesusWalks
1792.49: a_aDTR killed senord
1797.35: Map Desert_Main, weather Weather_Desert_Clear, region na, recorded by Hobocop, 94 players, 28 teams
=> Player senord, team 26 (ranked 2), 0 headshots, 5 kills, 0.00 damage, longest kill 0.00m, 0.00km covered
=> Player groxers, team 26 (ranked 2), 0 headshots, 1 kills, 0.00 damage, longest kill 0.00m, 0.00km covered
=> Player Hobocop, team 26 (ranked 2), 1 headshots, 2 kills, 128.62 damage, longest kill 678.34m, 22.82km covered
=begin
Copyright (c) 2017 Morgan Jones
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
=end
require 'json'
module PubgSummarizer
module Util
# This is very similar to how UE4 stores strings in its demo files. Just FYI.
# 32-bit dword for length (including null-terminator at end), null-terminated.
# The obfuscation appears to be done on each byte prior to it being null-terminated
# and is just x - 1. That was probably Bluehole's doing rather than the doing
# of the serialization stream format, though?
def self.unwrap io, deobfuscate=false
length = io.read(4).unpack('L').first
ret = io.read(length)
ret.chomp! "\x00"
if deobfuscate
(0...ret.bytesize).each do |i|
ret.setbyte(i, (ret.getbyte(i) + 1) & 0xff)
end
end
ret
end
end
module Unwrappable
def from_io io, _deobfuscate: false, **kw
unwrapped = Util.unwrap(io, _deobfuscate)
self.from_hash(JSON.parse(unwrapped, symbolize_names: true), **kw)
end
end
class PlayerCache
def initialize
@cache = {}
end
def intern id, name
id = Integer(id)
@cache[id] ||= Player.new(
id: id,
name: name
)
end
def nobody
@nobody ||= self.intern(0, '<nobody>')
end
end
class NamedStruct < Struct
def initialize **kw
super(*members.map {|k| kw[k]})
end
end
Match = NamedStruct.new(:name, :mode, :map, :length_ms, :_player_cache) do
extend Unwrappable
def player_cache
self._player_cache ||= PlayerCache.new
end
def length
self.length_ms / 1000.0
end
def self.from_hash h, **kw
# There's other stuff here that we won't worry about for now
self.new(
name: h[:FriendlyName],
mode: h[:Mode],
map: h[:MapName],
length_ms: h[:LengthInMS]
)
end
end
TimeDelta = NamedStruct.new(:t1, :t2) do
def start_time
self.t1 / 1000.0
end
def end_time
self.t2 / 1000.0
end
end
Player = NamedStruct.new(:id, :name) do
def nobody?
self.id == 0
end
def to_s
self.name
end
end
Meta = NamedStruct.new(:id, :data, :delta) do
extend Unwrappable
def self.from_hash h, id:
self.new(
id: id,
data: h[:meta],
delta: TimeDelta.new(
t1: Integer(h[:time1]),
t2: Integer(h[:time2])
)
)
end
end
PlayerInjury = NamedStruct.new(:meta, :instigator, :victim) do
extend Unwrappable
def self.create_instigator match, id, name
if id and id.length > 0 and name and name.length > 0
match.player_cache.intern(
Integer(id), name
)
else
# They weren't killed by another player
match.player_cache.nobody
end
end
def self.from_players instigator, victim, match:, meta:
self.new(
meta: meta,
instigator: instigator,
victim: victim
)
end
end
class Kill < PlayerInjury
def to_s
"#{self.instigator} killed #{self.victim}"
end
def self.from_hash h, match:, meta:
instigator = self.create_instigator(
match, h[:killerNetId], h[:killerName]
)
victim = self.create_instigator(
match, h[:victimNetId], h[:victimName]
)
self.from_players instigator, victim, match: match, meta: meta
end
end
class Knockout < PlayerInjury
def to_s
"#{self.instigator} knocked out #{self.victim}"
end
def self.from_hash h, match:, meta:
instigator = self.create_instigator(
match, h[:instigatorNetId], h[:instigatorName]
)
victim = self.create_instigator(
match, h[:victimNetId], h[:victimName]
)
self.from_players instigator, victim, match: match, meta: meta
end
end
Weather = NamedStruct.new(:meta, :id, :weight, :level) do
extend Unwrappable
def to_s
"Weather: #{self.id}, level #{self.level}, weight #{self.weight}"
end
def self.from_hash h, match:, meta:
self.new(
meta: meta,
id: h[:weatherId],
weight: Integer(h[:weight]),
level: h[:weatherLevel]
)
end
end
ReplaySummary = NamedStruct.new(
:meta, :recording_user, :map_name, :weather_name, :region_name,
:num_players, :num_teams, :player_summaries) do
extend Unwrappable
def to_s
ret = "Map #{self.map_name}, "
ret << "weather #{self.weather_name}, "
ret << "region #{self.region_name}, "
ret << "recorded by #{self.recording_user}, "
ret << "#{self.num_players} players, #{self.num_teams} teams\n"
self.player_summaries.each do |summary|
ret << " => "
ret << summary.to_s
ret << "\n"
end
ret.chomp!
ret
end
def self.from_hash h, match:, meta:
self.new(
meta: meta,
recording_user: match.player_cache.intern(
h[:recordUserId], h[:recordUserNickName]
),
map_name: h[:mapName],
weather_name: h[:weatherName],
region_name: h[:regionName],
num_players: h[:numPlayers],
num_teams: h[:numTeams],
player_summaries: h[:playerStateSummaries].map {|s|
PlayerSummary.from_hash(s, match: match)
}
)
end
end
PlayerSummary = NamedStruct.new(
:player, :team_id, :team_rank, :headshots, :kills,
:damage, :longest_kill, :distance_covered) do
extend Unwrappable
def to_s
ret = "Player #{self.player}, "
ret << "team #{self.team_id} (ranked #{self.team_rank}), "
ret << "#{self.headshots} headshots, "
ret << "#{self.kills} kills, "
ret << "#{'%.2f' % self.damage} damage, "
ret << "longest kill #{'%.2f' % self.longest_kill}m, "
ret << "#{'%.2f' % (self.distance_covered / 1000)}km covered"
ret
end
def self.from_hash h, match:
self.new(
player: match.player_cache.intern(
h[:uniqueId], h[:playerName]
),
team_id: Integer(h[:teamNumber]),
team_rank: Integer(h[:ranking]),
headshots: Integer(h[:headShots]),
kills: Integer(h[:numKills]),
damage: Float(h[:totalGivenDamages]),
longest_kill: Float(h[:longestDistanceKill]),
distance_covered: Float(h[:totalMovedDistanceMeter])
)
end
end
end
if __FILE__ == $0
if ARGV.length != 1
raise ArgumentError, "usage: #$0 <demo directory>"
end
include PubgSummarizer
root = ARGV[0]
# Create the match
match = nil
File.open(File.join(root, 'PUBG.replayinfo'), 'rb') do |replay_info|
match = Match.from_io(replay_info)
end
# Read metadata
meta_root = File.join(root, 'events')
data_root = File.join(root, 'data')
events = []
Dir.foreach(meta_root) do |filename|
case filename
when /\Aevent/, /\Akill/, /\Agroggy/, /\Alevel/, /\AReplaySummary/
meta = nil
File.open(File.join(meta_root, filename), 'rb') do |meta_file|
# Use the filename as the ID, since some meta files don't have one,
# and, for the cases when they do, it's identical to the filename
meta = Meta.from_io(meta_file, id: filename)
end
# Get the corresponding data file (if it exists...)
data = nil
data_filename = File.join(data_root, meta.id)
if File.exists?(data_filename)
File.open(data_filename, 'rb') do |data_file|
case meta.id
when /\Aevent/
puts "Surprise! We have data for #{meta.id} but don't know how to handle it."
when /\Akill/
data = Kill.from_io(data_file, _deobfuscate: true, match: match, meta: meta)
when /\Agroggy/
data = Knockout.from_io(data_file, _deobfuscate: true, match: match, meta: meta)
when /\Alevel/
data = Weather.from_io(data_file, _deobfuscate: true, match: match, meta: meta)
when /\AReplaySummary/
data = ReplaySummary.from_io(data_file, _deobfuscate: true, match: match, meta: meta)
end
end
end
end
if data
events << data
end
end
events.sort! do |e1, e2|
e1.meta.delta.start_time <=> e2.meta.delta.start_time
end
puts "Playing #{match.mode} on #{match.map} - took #{'%.2f' % (match.length / 60.0)} minutes"
events.each do |event|
puts "#{'%04.2f' % event.meta.delta.start_time}: #{event.to_s}"
end
puts "Match ended."
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment