Last active
July 9, 2019 17:13
-
-
Save numinit/35364ee3b943b5886a86780b10dd599d to your computer and use it in GitHub Desktop.
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.
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
# 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 |
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
=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